@nuasite/cms-sidecar 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/src/server.ts ADDED
@@ -0,0 +1,650 @@
1
+ import type { CmsCore, CmsFileSystem } from '@nuasite/cms-core'
2
+ import type { CollectionDefinition, CollectionEntry, CollectionEntryInfo, MutationResult } from '@nuasite/cms-types'
3
+ import { hashContent, hashSource, KeyedMutex } from './concurrency'
4
+ import {
5
+ type AddArrayItemBody,
6
+ type ApiError,
7
+ type Capabilities,
8
+ type ConflictResponse,
9
+ type CreateEntryBody,
10
+ type CreateFolderBody,
11
+ type EntriesListResult,
12
+ type EntriesQuery,
13
+ type ErrorCode,
14
+ type PageEntry,
15
+ type ProjectModel,
16
+ type RemoveArrayItemBody,
17
+ type RenameEntryBody,
18
+ STATUS_BY_CODE,
19
+ type UpdateEntryBody,
20
+ } from './types'
21
+
22
+ /** Features the sidecar advertises so older/newer clients can degrade gracefully. */
23
+ export const SIDECAR_FEATURES: readonly string[] = [
24
+ 'collections',
25
+ 'entries.fields-projection',
26
+ 'entries.draft-filter',
27
+ 'entries.pagination',
28
+ 'entry.crud',
29
+ 'entry.rename',
30
+ 'entry.array',
31
+ 'entry.optimistic-concurrency',
32
+ 'pages.crud',
33
+ 'pages.list',
34
+ 'pages.layouts',
35
+ 'redirects.crud',
36
+ 'media',
37
+ ]
38
+
39
+ const API_PREFIX = '/cms/v1'
40
+ const DEFAULT_LIMIT = 50
41
+ const MAX_LIMIT = 1000
42
+
43
+ export interface CreateServerOptions {
44
+ core: CmsCore
45
+ /** The same `CmsFileSystem` port the core was built over — used for hashing and the page walk. */
46
+ fs: CmsFileSystem
47
+ /** Resolved project root (absolute), surfaced in `/health`. */
48
+ root: string
49
+ /** Version reported as `coreVersion` (the cms-core package version). */
50
+ coreVersion: string
51
+ /** Content collections directory, relative to root. Defaults to `src/content`. */
52
+ contentDir?: string
53
+ /** Max accepted upload size in bytes. Defaults to 20 MiB. */
54
+ maxUploadSize?: number
55
+ }
56
+
57
+ // ============================================================================
58
+ // Response helpers
59
+ // ============================================================================
60
+
61
+ function json(body: unknown, status = 200): Response {
62
+ return new Response(JSON.stringify(body), {
63
+ status,
64
+ headers: { 'content-type': 'application/json; charset=utf-8' },
65
+ })
66
+ }
67
+
68
+ function error(code: ErrorCode, message: string, sourcePath?: string): Response {
69
+ const body: ApiError = { error: message, code }
70
+ if (sourcePath !== undefined) body.sourcePath = sourcePath
71
+ return json(body, STATUS_BY_CODE[code])
72
+ }
73
+
74
+ /**
75
+ * Map a cms-core `MutationResult` to an HTTP response. A failed result becomes a
76
+ * typed `ApiError`: "not found" → 404, everything else (validation, already
77
+ * exists, write failures) → the supplied default code.
78
+ */
79
+ function mutationResponse(result: MutationResult, fallback: ErrorCode = 'validation'): Response {
80
+ if (result.success) return json(result)
81
+ const message = result.error ?? 'Mutation failed'
82
+ const code: ErrorCode = /not found/i.test(message) ? 'not_found' : fallback
83
+ return error(code, message, result.sourcePath)
84
+ }
85
+
86
+ async function parseJson<T>(req: Request): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
87
+ try {
88
+ // `JSON.parse` returns `any`, which flows to `T` without a cast. Body shapes
89
+ // are field-validated per route before use, never trusted blindly.
90
+ const text = await req.text()
91
+ const value: T = JSON.parse(text)
92
+ return { ok: true, value }
93
+ } catch {
94
+ return { ok: false, response: error('validation', 'Invalid JSON body') }
95
+ }
96
+ }
97
+
98
+ function parseEntriesQuery(url: URL): EntriesQuery {
99
+ const fields = url.searchParams.get('fields') ?? undefined
100
+ const rawDraft = url.searchParams.get('draft')
101
+ const draft: EntriesQuery['draft'] = rawDraft === 'true' || rawDraft === 'all' ? rawDraft : 'false'
102
+
103
+ const rawLimit = url.searchParams.get('limit')
104
+ let limit: number | undefined
105
+ if (rawLimit !== null) {
106
+ const parsed = Number.parseInt(rawLimit, 10)
107
+ if (Number.isFinite(parsed) && parsed > 0) limit = Math.min(parsed, MAX_LIMIT)
108
+ }
109
+ const cursor = url.searchParams.get('cursor') ?? undefined
110
+ return { fields, draft, limit, cursor }
111
+ }
112
+
113
+ // ============================================================================
114
+ // Entry projection (fields)
115
+ // ============================================================================
116
+
117
+ /**
118
+ * Project a scanned `CollectionEntryInfo` down to the requested `fields`.
119
+ *
120
+ * - absent: light header (slug/title/draft/pathname/sourcePath), never the body.
121
+ * - `*`: all frontmatter (via `data`), still no body.
122
+ * - `a,b`: those frontmatter keys (plus the always-present slug/sourcePath).
123
+ *
124
+ * The body lives only behind the entry-detail route, so a list stays small even
125
+ * for large/data collections.
126
+ */
127
+ function projectEntry(entry: CollectionEntryInfo, fields: string | undefined): CollectionEntryInfo {
128
+ if (fields === '*') {
129
+ // All frontmatter, still no body. `data` already carries the full frontmatter.
130
+ const projected: CollectionEntryInfo = { slug: entry.slug, sourcePath: entry.sourcePath }
131
+ if (entry.title !== undefined) projected.title = entry.title
132
+ if (entry.draft !== undefined) projected.draft = entry.draft
133
+ if (entry.pathname !== undefined) projected.pathname = entry.pathname
134
+ if (entry.data !== undefined) projected.data = entry.data
135
+ return projected
136
+ }
137
+
138
+ if (fields === undefined || fields.trim() === '') {
139
+ const projected: CollectionEntryInfo = { slug: entry.slug, sourcePath: entry.sourcePath }
140
+ if (entry.title !== undefined) projected.title = entry.title
141
+ if (entry.draft !== undefined) projected.draft = entry.draft
142
+ if (entry.pathname !== undefined) projected.pathname = entry.pathname
143
+ return projected
144
+ }
145
+
146
+ const keys = fields.split(',').map(k => k.trim()).filter(Boolean)
147
+ // slug + sourcePath are always-present identity fields.
148
+ const projected: CollectionEntryInfo = { slug: entry.slug, sourcePath: entry.sourcePath }
149
+ const data: Record<string, unknown> = {}
150
+ for (const key of keys) {
151
+ switch (key) {
152
+ case 'slug':
153
+ case 'sourcePath':
154
+ break
155
+ case 'title':
156
+ if (entry.title !== undefined) projected.title = entry.title
157
+ break
158
+ case 'draft':
159
+ if (entry.draft !== undefined) projected.draft = entry.draft
160
+ break
161
+ case 'pathname':
162
+ if (entry.pathname !== undefined) projected.pathname = entry.pathname
163
+ break
164
+ default: {
165
+ const value = entry.data?.[key]
166
+ if (value !== undefined) data[key] = value
167
+ }
168
+ }
169
+ }
170
+ if (Object.keys(data).length > 0) projected.data = data
171
+ return projected
172
+ }
173
+
174
+ /** Apply the draft filter to a scanned entry list. */
175
+ function filterByDraft(entries: CollectionEntryInfo[], draft: EntriesQuery['draft']): CollectionEntryInfo[] {
176
+ if (draft === 'all') return entries
177
+ const wantDraft = draft === 'true'
178
+ return entries.filter(e => (e.draft === true) === wantDraft)
179
+ }
180
+
181
+ /**
182
+ * Opaque-but-real cursor: a base64url offset into the (stably scan-ordered) entry
183
+ * list. We never silently cap — when more remain, the next cursor is returned and
184
+ * `hasMore` is set; absent a cursor we start at offset 0.
185
+ */
186
+ function decodeCursor(cursor: string | undefined): number {
187
+ if (!cursor) return 0
188
+ try {
189
+ const decoded = Buffer.from(cursor, 'base64url').toString('utf-8')
190
+ const offset = Number.parseInt(decoded, 10)
191
+ return Number.isFinite(offset) && offset >= 0 ? offset : 0
192
+ } catch {
193
+ return 0
194
+ }
195
+ }
196
+
197
+ function encodeCursor(offset: number): string {
198
+ return Buffer.from(String(offset), 'utf-8').toString('base64url')
199
+ }
200
+
201
+ // ============================================================================
202
+ // Page list (sidecar-layer fs walk; cms-core has no page-listing capability)
203
+ // ============================================================================
204
+
205
+ const PAGE_EXTENSIONS = ['.astro', '.md', '.mdx']
206
+
207
+ /**
208
+ * Walk `src/pages` through the `CmsFileSystem` port and derive static page
209
+ * routes. Skips underscore/dot files, dynamic segments (`[...]`) and non-page
210
+ * extensions — mirroring the dev server's filesystem page discovery. Yields
211
+ * `pathname` only; an SEO `title` would require the render manifest (out of scope).
212
+ */
213
+ async function listPages(fs: CmsFileSystem): Promise<PageEntry[]> {
214
+ const pathnames: string[] = []
215
+
216
+ async function walk(dir: string, urlPrefix: string): Promise<void> {
217
+ const entries = await fs.list(dir)
218
+ for (const entry of entries) {
219
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
220
+ if (entry.name.includes('[')) continue
221
+ const full = `${dir}/${entry.name}`
222
+ if (entry.isDirectory) {
223
+ await walk(full, `${urlPrefix}${entry.name}/`)
224
+ continue
225
+ }
226
+ const dot = entry.name.lastIndexOf('.')
227
+ const ext = dot >= 0 ? entry.name.slice(dot) : ''
228
+ if (!PAGE_EXTENSIONS.includes(ext)) continue
229
+ const baseName = entry.name.slice(0, entry.name.length - ext.length)
230
+ const pathname = baseName === 'index'
231
+ ? (urlPrefix.replace(/\/$/, '') || '/')
232
+ : `${urlPrefix}${baseName}`
233
+ pathnames.push(pathname)
234
+ }
235
+ }
236
+
237
+ await walk('src/pages', '/')
238
+ pathnames.sort((a, b) => a.localeCompare(b))
239
+ return pathnames.map(pathname => ({ pathname }))
240
+ }
241
+
242
+ // ============================================================================
243
+ // Router
244
+ // ============================================================================
245
+
246
+ export interface CmsSidecarServer {
247
+ /** The `fetch` handler — pass to `Bun.serve`, or drive directly in tests. */
248
+ fetch(req: Request): Promise<Response>
249
+ }
250
+
251
+ export function createServer(opts: CreateServerOptions): CmsSidecarServer {
252
+ const { core, fs, root, coreVersion } = opts
253
+ const contentDir = opts.contentDir ?? 'src/content'
254
+ const maxUploadSize = opts.maxUploadSize ?? 20 * 1024 * 1024
255
+ const mutex = new KeyedMutex()
256
+
257
+ async function scanList(): Promise<CollectionDefinition[]> {
258
+ const map = await core.scanCollections()
259
+ return Object.values(map)
260
+ }
261
+
262
+ async function resolveCollection(name: string): Promise<CollectionDefinition | null> {
263
+ const map = await core.scanCollections()
264
+ return map[name] ?? null
265
+ }
266
+
267
+ // --- Entry detail (assembles the CollectionEntry wire shape) ---
268
+ async function entryDetail(collection: string, slug: string): Promise<Response> {
269
+ const result = await core.getEntry(collection, slug)
270
+ if (!result) return error('not_found', `Entry not found: ${collection}/${slug}`)
271
+
272
+ const def = await resolveCollection(collection)
273
+ // frontmatter values are stringified for the line-keyed wire shape; line
274
+ // numbers are not tracked headlessly (the inline widget owns line-precise edits).
275
+ const frontmatter: Record<string, { value: string; line: number }> = {}
276
+ for (const [key, value] of Object.entries(result.frontmatter)) {
277
+ frontmatter[key] = { value: typeof value === 'string' ? value : JSON.stringify(value), line: 0 }
278
+ }
279
+ const entry: CollectionEntry = {
280
+ collectionName: def?.name ?? collection,
281
+ collectionSlug: slug,
282
+ sourcePath: result.sourcePath,
283
+ frontmatter,
284
+ body: result.content,
285
+ bodyStartLine: 0,
286
+ }
287
+ return json(entry)
288
+ }
289
+
290
+ // --- PATCH entry (optimistic concurrency + per-file mutex) ---
291
+ async function patchEntry(collection: string, slug: string, body: UpdateEntryBody): Promise<Response> {
292
+ const existing = await core.getEntry(collection, slug)
293
+ if (!existing) return error('not_found', `Entry not found: ${collection}/${slug}`)
294
+ const sourcePath = existing.sourcePath
295
+
296
+ return mutex.runExclusive(sourcePath, async () => {
297
+ // Re-hash under the lock so a concurrent writer cannot slip in between.
298
+ const serverHash = await hashSource(fs, sourcePath)
299
+ if (body.baseHash !== undefined && serverHash !== null && body.baseHash !== serverHash) {
300
+ const current = await core.getEntry(collection, slug)
301
+ const conflict: ConflictResponse = {
302
+ code: 'conflict',
303
+ serverHash,
304
+ serverFrontmatter: current?.frontmatter ?? existing.frontmatter,
305
+ }
306
+ if (current && current.content !== '') conflict.serverBody = current.content
307
+ return json(conflict, STATUS_BY_CODE.conflict)
308
+ }
309
+
310
+ const result = await core.updateEntry({
311
+ collection,
312
+ slug,
313
+ frontmatter: body.frontmatter,
314
+ body: body.body,
315
+ })
316
+ if (!result.success) return mutationResponse(result)
317
+
318
+ const newHash = await hashSource(fs, result.sourcePath ?? sourcePath)
319
+ const enriched: MutationResult = { ...result }
320
+ if (newHash !== null) enriched.sourceHash = newHash
321
+ return json(enriched)
322
+ })
323
+ }
324
+
325
+ async function fetchHandler(req: Request): Promise<Response> {
326
+ const url = new URL(req.url)
327
+ let pathname = url.pathname
328
+ if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1)
329
+
330
+ // /health is unversioned (liveness probe).
331
+ if (pathname === '/health' && req.method === 'GET') {
332
+ return json({ ok: true, coreVersion, root })
333
+ }
334
+
335
+ if (!pathname.startsWith(API_PREFIX)) {
336
+ return error('not_found', `No route: ${req.method} ${pathname}`)
337
+ }
338
+ const rest = pathname.slice(API_PREFIX.length) || '/'
339
+ const segments = rest.split('/').filter(Boolean).map(decodeURIComponent)
340
+ const method = req.method
341
+
342
+ return route(method, segments, req, url)
343
+ }
344
+
345
+ async function route(method: string, segments: string[], req: Request, url: URL): Promise<Response> {
346
+ const [head, ...tail] = segments
347
+
348
+ switch (head) {
349
+ case 'health':
350
+ if (method === 'GET') return json({ ok: true, coreVersion, root })
351
+ break
352
+
353
+ case 'project':
354
+ if (method === 'GET') {
355
+ const [collections, pages] = await Promise.all([scanList(), listPages(fs)])
356
+ const capabilities: Capabilities = { coreVersion, features: [...SIDECAR_FEATURES] }
357
+ const model: ProjectModel = { collections, pages, capabilities }
358
+ return json(model)
359
+ }
360
+ break
361
+
362
+ case 'collections':
363
+ return routeCollections(method, tail, req, url)
364
+
365
+ case 'pages':
366
+ return routePages(method, tail, req)
367
+
368
+ case 'redirects':
369
+ return routeRedirects(method, tail, req)
370
+
371
+ case 'media':
372
+ return routeMedia(method, tail, req, url)
373
+ }
374
+
375
+ return error('not_found', `No route: ${method} /cms/v1/${segments.join('/')}`)
376
+ }
377
+
378
+ async function routeCollections(method: string, tail: string[], req: Request, url: URL): Promise<Response> {
379
+ // GET /collections
380
+ if (tail.length === 0) {
381
+ if (method === 'GET') return json(await scanList())
382
+ return error('unsupported', `Unsupported: ${method} /collections`)
383
+ }
384
+
385
+ const [collection, sub, slug, action] = tail
386
+
387
+ // /collections/:c/entries[...]
388
+ if (sub === 'entries' && collection) {
389
+ // GET /collections/:c/entries (sparse list)
390
+ if (slug === undefined) {
391
+ if (method === 'GET') return listEntries(collection, url)
392
+ if (method === 'POST') return createEntry(collection, req)
393
+ return error('unsupported', `Unsupported: ${method} /collections/${collection}/entries`)
394
+ }
395
+
396
+ // /collections/:c/entries/:slug[...]
397
+ if (action === undefined) {
398
+ if (method === 'GET') return entryDetail(collection, slug)
399
+ if (method === 'PATCH') return updateEntryRoute(collection, slug, req)
400
+ if (method === 'DELETE') return deleteEntryRoute(collection, slug)
401
+ return error('unsupported', `Unsupported: ${method} /collections/${collection}/entries/${slug}`)
402
+ }
403
+
404
+ if (action === 'rename' && method === 'POST') return renameEntryRoute(collection, slug, req)
405
+ if (action === 'array' && method === 'POST') return addArrayRoute(collection, slug, req)
406
+ if (action === 'array' && method === 'DELETE') return removeArrayRoute(collection, slug, req)
407
+ }
408
+
409
+ return error('not_found', `No route: ${method} /cms/v1/collections/${tail.join('/')}`)
410
+ }
411
+
412
+ async function listEntries(collection: string, url: URL): Promise<Response> {
413
+ const def = await resolveCollection(collection)
414
+ if (!def) return error('not_found', `Collection not found: ${collection}`)
415
+
416
+ const query = parseEntriesQuery(url)
417
+ const all = def.entries ?? []
418
+ const filtered = filterByDraft(all, query.draft)
419
+
420
+ const offset = decodeCursor(query.cursor)
421
+ const limit = query.limit ?? DEFAULT_LIMIT
422
+ const page = filtered.slice(offset, offset + limit)
423
+ const hasMore = offset + limit < filtered.length
424
+
425
+ const entries = page.map(e => projectEntry(e, query.fields))
426
+ const result: EntriesListResult = { entries, hasMore }
427
+ if (hasMore) result.cursor = encodeCursor(offset + limit)
428
+ return json(result)
429
+ }
430
+
431
+ async function createEntry(collection: string, req: Request): Promise<Response> {
432
+ const parsed = await parseJson<CreateEntryBody>(req)
433
+ if (!parsed.ok) return parsed.response
434
+ const body = parsed.value
435
+ if (typeof body.slug !== 'string' || body.slug.trim() === '') {
436
+ return error('validation', 'A non-empty "slug" is required')
437
+ }
438
+ if (body.frontmatter === undefined || typeof body.frontmatter !== 'object') {
439
+ return error('validation', 'A "frontmatter" object is required')
440
+ }
441
+
442
+ const result = await core.createEntry({
443
+ collection,
444
+ slug: body.slug,
445
+ frontmatter: body.frontmatter,
446
+ body: body.body,
447
+ fileExtension: body.fileExtension,
448
+ })
449
+ if (!result.success) return mutationResponse(result)
450
+
451
+ const newHash = result.sourcePath ? await hashSource(fs, result.sourcePath) : null
452
+ const enriched: MutationResult = { ...result }
453
+ if (newHash !== null) enriched.sourceHash = newHash
454
+ return json(enriched)
455
+ }
456
+
457
+ async function updateEntryRoute(collection: string, slug: string, req: Request): Promise<Response> {
458
+ const parsed = await parseJson<UpdateEntryBody>(req)
459
+ if (!parsed.ok) return parsed.response
460
+ return patchEntry(collection, slug, parsed.value)
461
+ }
462
+
463
+ async function deleteEntryRoute(collection: string, slug: string): Promise<Response> {
464
+ const existing = await core.getEntry(collection, slug)
465
+ if (!existing) return error('not_found', `Entry not found: ${collection}/${slug}`)
466
+ return mutex.runExclusive(existing.sourcePath, async () => {
467
+ const result = await core.deleteEntry(collection, slug)
468
+ return mutationResponse(result)
469
+ })
470
+ }
471
+
472
+ async function renameEntryRoute(collection: string, slug: string, req: Request): Promise<Response> {
473
+ const parsed = await parseJson<RenameEntryBody>(req)
474
+ if (!parsed.ok) return parsed.response
475
+ if (typeof parsed.value.to !== 'string' || parsed.value.to.trim() === '') {
476
+ return error('validation', 'A non-empty "to" slug is required')
477
+ }
478
+ const existing = await core.getEntry(collection, slug)
479
+ if (!existing) return error('not_found', `Entry not found: ${collection}/${slug}`)
480
+ return mutex.runExclusive(existing.sourcePath, async () => {
481
+ const result = await core.renameEntry(collection, slug, parsed.value.to)
482
+ if (!result.success) return mutationResponse(result)
483
+ const newHash = result.sourcePath ? await hashSource(fs, result.sourcePath) : null
484
+ const enriched: MutationResult = { ...result }
485
+ if (newHash !== null) enriched.sourceHash = newHash
486
+ return json(enriched)
487
+ })
488
+ }
489
+
490
+ async function addArrayRoute(collection: string, slug: string, req: Request): Promise<Response> {
491
+ const parsed = await parseJson<AddArrayItemBody>(req)
492
+ if (!parsed.ok) return parsed.response
493
+ const body = parsed.value
494
+ if (typeof body.field !== 'string' || body.field.trim() === '') {
495
+ return error('validation', 'A non-empty "field" is required')
496
+ }
497
+ const existing = await core.getEntry(collection, slug)
498
+ if (!existing) return error('not_found', `Entry not found: ${collection}/${slug}`)
499
+ return mutex.runExclusive(existing.sourcePath, async () => {
500
+ const result = await core.addArrayItem({ collection, slug, field: body.field, value: body.value, index: body.index })
501
+ return mutationResponse(result)
502
+ })
503
+ }
504
+
505
+ async function removeArrayRoute(collection: string, slug: string, req: Request): Promise<Response> {
506
+ const parsed = await parseJson<RemoveArrayItemBody>(req)
507
+ if (!parsed.ok) return parsed.response
508
+ const body = parsed.value
509
+ if (typeof body.field !== 'string' || body.field.trim() === '') {
510
+ return error('validation', 'A non-empty "field" is required')
511
+ }
512
+ if (typeof body.index !== 'number' || !Number.isInteger(body.index)) {
513
+ return error('validation', 'An integer "index" is required')
514
+ }
515
+ const existing = await core.getEntry(collection, slug)
516
+ if (!existing) return error('not_found', `Entry not found: ${collection}/${slug}`)
517
+ return mutex.runExclusive(existing.sourcePath, async () => {
518
+ const result = await core.removeArrayItem({ collection, slug, field: body.field, index: body.index })
519
+ return mutationResponse(result)
520
+ })
521
+ }
522
+
523
+ async function routePages(method: string, tail: string[], req: Request): Promise<Response> {
524
+ const [sub] = tail
525
+
526
+ // GET /pages → fs-derived page list (cms-core has no page listing).
527
+ if (sub === undefined) {
528
+ if (method === 'GET') return json({ pages: await listPages(fs) })
529
+ if (method === 'POST') {
530
+ const parsed = await parseJson<Parameters<CmsCore['createPage']>[0]>(req)
531
+ if (!parsed.ok) return parsed.response
532
+ const result = await core.createPage(parsed.value)
533
+ return result.success ? json(result) : error('validation', result.error ?? 'Failed to create page')
534
+ }
535
+ if (method === 'DELETE') {
536
+ const parsed = await parseJson<Parameters<CmsCore['deletePage']>[0]>(req)
537
+ if (!parsed.ok) return parsed.response
538
+ const result = await core.deletePage(parsed.value)
539
+ if (result.success) return json(result)
540
+ const code: ErrorCode = /not found/i.test(result.error ?? '') ? 'not_found' : 'validation'
541
+ return error(code, result.error ?? 'Failed to delete page')
542
+ }
543
+ return error('unsupported', `Unsupported: ${method} /pages`)
544
+ }
545
+
546
+ // GET /pages/layouts
547
+ if (sub === 'layouts' && method === 'GET') {
548
+ return json({ layouts: await core.getLayouts() })
549
+ }
550
+
551
+ // POST /pages/duplicate
552
+ if (sub === 'duplicate' && method === 'POST') {
553
+ const parsed = await parseJson<Parameters<CmsCore['duplicatePage']>[0]>(req)
554
+ if (!parsed.ok) return parsed.response
555
+ const result = await core.duplicatePage(parsed.value)
556
+ if (result.success) return json(result)
557
+ const code: ErrorCode = /not found/i.test(result.error ?? '') ? 'not_found' : 'validation'
558
+ return error(code, result.error ?? 'Failed to duplicate page')
559
+ }
560
+
561
+ return error('not_found', `No route: ${method} /cms/v1/pages/${tail.join('/')}`)
562
+ }
563
+
564
+ async function routeRedirects(method: string, tail: string[], req: Request): Promise<Response> {
565
+ const [sub] = tail
566
+
567
+ if (sub === undefined) {
568
+ if (method === 'GET') return json(await core.listRedirects())
569
+ if (method === 'POST') {
570
+ const parsed = await parseJson<Parameters<CmsCore['addRedirect']>[0]>(req)
571
+ if (!parsed.ok) return parsed.response
572
+ const result = await core.addRedirect(parsed.value)
573
+ return result.success ? json(result) : error('validation', result.error ?? 'Failed to add redirect')
574
+ }
575
+ if (method === 'PATCH') {
576
+ const parsed = await parseJson<Parameters<CmsCore['updateRedirect']>[0]>(req)
577
+ if (!parsed.ok) return parsed.response
578
+ const result = await core.updateRedirect(parsed.value)
579
+ return result.success ? json(result) : error('validation', result.error ?? 'Failed to update redirect')
580
+ }
581
+ if (method === 'DELETE') {
582
+ const parsed = await parseJson<Parameters<CmsCore['deleteRedirect']>[0]>(req)
583
+ if (!parsed.ok) return parsed.response
584
+ const result = await core.deleteRedirect(parsed.value)
585
+ return result.success ? json(result) : error('validation', result.error ?? 'Failed to delete redirect')
586
+ }
587
+ return error('unsupported', `Unsupported: ${method} /redirects`)
588
+ }
589
+
590
+ return error('not_found', `No route: ${method} /cms/v1/redirects/${tail.join('/')}`)
591
+ }
592
+
593
+ async function routeMedia(method: string, tail: string[], req: Request, url: URL): Promise<Response> {
594
+ const adapter = core.media
595
+ if (!adapter) return error('unsupported', 'Media storage not configured')
596
+
597
+ const [id] = tail
598
+
599
+ // GET /media
600
+ if (id === undefined && method === 'GET') {
601
+ const rawLimit = url.searchParams.get('limit')
602
+ const parsedLimit = rawLimit !== null ? Number.parseInt(rawLimit, 10) : Number.NaN
603
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, MAX_LIMIT) : DEFAULT_LIMIT
604
+ const result = await adapter.list({
605
+ limit,
606
+ cursor: url.searchParams.get('cursor') ?? undefined,
607
+ folder: url.searchParams.get('folder') ?? undefined,
608
+ })
609
+ return json(result)
610
+ }
611
+
612
+ // POST /media (multipart upload, or JSON create-folder)
613
+ if (id === undefined && method === 'POST') {
614
+ const contentType = req.headers.get('content-type') ?? ''
615
+ if (contentType.includes('application/json')) {
616
+ const parsed = await parseJson<CreateFolderBody>(req)
617
+ if (!parsed.ok) return parsed.response
618
+ if (!adapter.createFolder) return error('unsupported', 'Folder creation not supported by this adapter')
619
+ if (typeof parsed.value.folder !== 'string' || parsed.value.folder.includes('..')) {
620
+ return error('validation', 'A valid "folder" is required')
621
+ }
622
+ const result = await adapter.createFolder(parsed.value.folder)
623
+ return result.success ? json(result) : error('io_error', result.error ?? 'Failed to create folder')
624
+ }
625
+ if (!contentType.includes('multipart/form-data')) {
626
+ return error('validation', 'Expected multipart/form-data or application/json')
627
+ }
628
+ const form = await req.formData()
629
+ const file = form.get('file')
630
+ if (!(file instanceof File)) return error('validation', 'No "file" found in the form data')
631
+ if (file.size > maxUploadSize) {
632
+ return error('validation', `File too large (max ${Math.round(maxUploadSize / (1024 * 1024))} MB)`)
633
+ }
634
+ const buffer = Buffer.from(await file.arrayBuffer())
635
+ const folder = url.searchParams.get('folder') ?? undefined
636
+ const result = await adapter.upload(buffer, file.name, file.type || 'application/octet-stream', { folder })
637
+ return result.success ? json(result) : error('io_error', result.error ?? 'Upload failed')
638
+ }
639
+
640
+ // DELETE /media/:id
641
+ if (id !== undefined && method === 'DELETE') {
642
+ const result = await adapter.delete(id)
643
+ return result.success ? json({ success: true }) : error('io_error', result.error ?? 'Delete failed')
644
+ }
645
+
646
+ return error('not_found', `No route: ${method} /cms/v1/media${id ? `/${id}` : ''}`)
647
+ }
648
+
649
+ return { fetch: fetchHandler }
650
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../tsconfig.settings.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/types"
5
+ },
6
+ "references": [
7
+ { "path": "../../cms-core/src" },
8
+ { "path": "../../cms-types/src" }
9
+ ]
10
+ }