@nuasite/cms 0.18.1 → 0.19.0

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.
Files changed (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -1,4 +1,5 @@
1
1
  import { batch, computed, type Signal, signal } from '@preact/signals'
2
+ import { slugifyHref } from '../shared'
2
3
  import { fetchManifest, getMarkdownContent } from './api'
3
4
  import type { ToastMessage, ToastType } from './components/toast/types'
4
5
  import { getConfig } from './config'
@@ -18,12 +19,14 @@ import type {
18
19
  ComponentInstance,
19
20
  ConfirmDialogState,
20
21
  CreatePageState,
22
+ DeletePageState,
21
23
  DeploymentState,
22
24
  DeploymentStatusType,
23
25
  EditorState,
24
26
  FieldDefinition,
25
27
  MarkdownEditorState,
26
28
  MarkdownPageEntry,
29
+ MdxPropsEditorState,
27
30
  MediaItem,
28
31
  MediaLibraryState,
29
32
  PendingAttributeChange,
@@ -33,6 +36,7 @@ import type {
33
36
  PendingComponentInsert,
34
37
  PendingImageChange,
35
38
  PendingSeoChange,
39
+ RedirectsManagerState,
36
40
  SeoEditorState,
37
41
  } from './types'
38
42
 
@@ -147,6 +151,26 @@ function createInitialCreatePageState(): CreatePageState {
147
151
  isOpen: false,
148
152
  isCreating: false,
149
153
  selectedCollection: null,
154
+ mode: 'pick',
155
+ }
156
+ }
157
+
158
+ function createInitialDeletePageState(): DeletePageState {
159
+ return {
160
+ isOpen: false,
161
+ isDeleting: false,
162
+ targetPage: null,
163
+ redirectTo: '/',
164
+ createRedirect: true,
165
+ }
166
+ }
167
+
168
+ function createInitialRedirectsManagerState(): RedirectsManagerState {
169
+ return {
170
+ isOpen: false,
171
+ rules: [],
172
+ isLoading: false,
173
+ editingIndex: null,
150
174
  }
151
175
  }
152
176
 
@@ -318,6 +342,76 @@ export const currentMarkdownPage = computed(
318
342
  )
319
343
  export const isMarkdownPreview = signal(false)
320
344
 
345
+ // ============================================================================
346
+ // MDX Component Block State Signals
347
+ // ============================================================================
348
+
349
+ export const mdxPropsEditorState = signal<MdxPropsEditorState>({
350
+ isOpen: false,
351
+ nodePos: null,
352
+ componentName: null,
353
+ props: {},
354
+ cursorPos: null,
355
+ })
356
+
357
+ export const mdxComponentPickerOpen = signal(false)
358
+
359
+ export function openMdxPropsEditor(nodePos: number, componentName: string, props: Record<string, string>, cursorPos: { x: number; y: number }): void {
360
+ mdxPropsEditorState.value = { isOpen: true, nodePos, componentName, props, cursorPos }
361
+ }
362
+
363
+ export function closeMdxPropsEditor(): void {
364
+ mdxPropsEditorState.value = { isOpen: false, nodePos: null, componentName: null, props: {}, cursorPos: null }
365
+ }
366
+
367
+ // ============================================================================
368
+ // Reference Picker State Signals
369
+ // ============================================================================
370
+
371
+ export interface ReferencePickerState {
372
+ isOpen: boolean
373
+ /** CMS ID of the clicked element */
374
+ cmsId: string | null
375
+ /** Reference field name in the owning entry (e.g., 'author') */
376
+ fieldName: string | null
377
+ /** Collection to pick from (e.g., 'authors') */
378
+ collection: string | null
379
+ /** Current reference value (slug) */
380
+ currentValue: string | null
381
+ /** File path of the owning entry to update */
382
+ ownerPath: string | null
383
+ /** Whether this is an array reference (multi-select) */
384
+ isArray: boolean
385
+ /** Current array values for array references */
386
+ currentValues: string[]
387
+ /** Position for the floating panel */
388
+ cursorPos: { x: number; y: number } | null
389
+ }
390
+
391
+ function createInitialReferencePickerState(): ReferencePickerState {
392
+ return {
393
+ isOpen: false,
394
+ cmsId: null,
395
+ fieldName: null,
396
+ collection: null,
397
+ currentValue: null,
398
+ ownerPath: null,
399
+ isArray: false,
400
+ currentValues: [],
401
+ cursorPos: null,
402
+ }
403
+ }
404
+
405
+ export const referencePickerState = signal<ReferencePickerState>(createInitialReferencePickerState())
406
+
407
+ export function openReferencePicker(opts: Omit<ReferencePickerState, 'isOpen'>): void {
408
+ referencePickerState.value = { isOpen: true, ...opts }
409
+ }
410
+
411
+ export function closeReferencePicker(): void {
412
+ referencePickerState.value = createInitialReferencePickerState()
413
+ }
414
+
321
415
  // ============================================================================
322
416
  // Media Library State Signals
323
417
  // ============================================================================
@@ -328,10 +422,6 @@ export const mediaLibraryState = signal<MediaLibraryState>(
328
422
 
329
423
  // Convenience computed signals for media library
330
424
  export const isMediaLibraryOpen = computed(() => mediaLibraryState.value.isOpen)
331
- export const mediaLibraryItems = computed(() => mediaLibraryState.value.items)
332
- export const isMediaLibraryLoading = computed(
333
- () => mediaLibraryState.value.isLoading,
334
- )
335
425
 
336
426
  // ============================================================================
337
427
  // Create Page State Signals
@@ -345,6 +435,25 @@ export const createPageState = signal<CreatePageState>(
345
435
  export const isCreatePageOpen = computed(() => createPageState.value.isOpen)
346
436
  export const isCreatingPage = computed(() => createPageState.value.isCreating)
347
437
  export const selectedCollection = computed(() => createPageState.value.selectedCollection)
438
+ export const createPageMode = computed(() => createPageState.value.mode)
439
+
440
+ // ============================================================================
441
+ // Delete Page State Signals
442
+ // ============================================================================
443
+
444
+ export const deletePageState = signal<DeletePageState>(
445
+ createInitialDeletePageState(),
446
+ )
447
+ export const isDeletePageOpen = computed(() => deletePageState.value.isOpen)
448
+
449
+ // ============================================================================
450
+ // Redirects Manager State Signals
451
+ // ============================================================================
452
+
453
+ export const redirectsManagerState = signal<RedirectsManagerState>(
454
+ createInitialRedirectsManagerState(),
455
+ )
456
+ export const isRedirectsManagerOpen = computed(() => redirectsManagerState.value.isOpen)
348
457
 
349
458
  // ============================================================================
350
459
  // Collections Browser State Signals
@@ -806,6 +915,9 @@ export function setMarkdownActiveElement(elementId: string | null): void {
806
915
 
807
916
  export function updateMarkdownFrontmatter(updates: Partial<import('./types').BlogFrontmatter>): void {
808
917
  if (markdownEditorState.value.currentPage) {
918
+ // Auto-sync derived fields (e.g. categoryHref from category)
919
+ const derivedUpdates = computeDerivedUpdates(updates)
920
+
809
921
  markdownEditorState.value = {
810
922
  ...markdownEditorState.value,
811
923
  currentPage: {
@@ -813,6 +925,7 @@ export function updateMarkdownFrontmatter(updates: Partial<import('./types').Blo
813
925
  frontmatter: {
814
926
  ...markdownEditorState.value.currentPage.frontmatter,
815
927
  ...updates,
928
+ ...derivedUpdates,
816
929
  },
817
930
  isDirty: true,
818
931
  },
@@ -820,6 +933,33 @@ export function updateMarkdownFrontmatter(updates: Partial<import('./types').Blo
820
933
  }
821
934
  }
822
935
 
936
+ function computeDerivedUpdates(updates: Record<string, unknown>): Record<string, unknown> {
937
+ const fields = markdownEditorState.value.collectionDefinition?.fields
938
+ if (!fields) return {}
939
+
940
+ const result: Record<string, unknown> = {}
941
+ for (const field of fields) {
942
+ if (!field.derivedFrom || !field.hidden) continue
943
+ const sourceValue = updates[field.derivedFrom]
944
+ if (typeof sourceValue !== 'string') continue
945
+ result[field.name] = slugifyHref(sourceValue)
946
+ }
947
+ return result
948
+ }
949
+
950
+ export function updateMarkdownPageMeta(patch: Partial<Pick<MarkdownPageEntry, 'slug' | 'filePath'>>): void {
951
+ if (markdownEditorState.value.currentPage) {
952
+ markdownEditorState.value = {
953
+ ...markdownEditorState.value,
954
+ currentPage: {
955
+ ...markdownEditorState.value.currentPage,
956
+ ...patch,
957
+ isDirty: true,
958
+ },
959
+ }
960
+ }
961
+ }
962
+
823
963
  export function resetMarkdownEditorState(): void {
824
964
  markdownEditorState.value = createInitialMarkdownEditorState()
825
965
  }
@@ -923,6 +1063,7 @@ export function openMarkdownEditorForNewPage(
923
1063
  const initialFrontmatter: Record<string, unknown> = {}
924
1064
  for (const field of collectionDefinition.fields) {
925
1065
  if (field.name === 'title') continue // title handled separately via the header
1066
+ if (field.hidden) continue // derived fields are auto-computed
926
1067
  if (field.defaultValue !== undefined) {
927
1068
  initialFrontmatter[field.name] = field.defaultValue
928
1069
  } else {
@@ -975,14 +1116,6 @@ export function setMediaLibraryOpen(open: boolean): void {
975
1116
  mediaLibraryState.value = { ...mediaLibraryState.value, isOpen: open }
976
1117
  }
977
1118
 
978
- export function setMediaLibraryItems(items: MediaItem[]): void {
979
- mediaLibraryState.value = { ...mediaLibraryState.value, items }
980
- }
981
-
982
- export function setMediaLibraryLoading(loading: boolean): void {
983
- mediaLibraryState.value = { ...mediaLibraryState.value, isLoading: loading }
984
- }
985
-
986
1119
  export function setMediaLibrarySelectedItem(item: MediaItem | null): void {
987
1120
  mediaLibraryState.value = { ...mediaLibraryState.value, selectedItem: item }
988
1121
  }
@@ -1012,7 +1145,11 @@ export function resetMediaLibraryState(): void {
1012
1145
  // ============================================================================
1013
1146
 
1014
1147
  export function setCreatePageOpen(open: boolean): void {
1015
- createPageState.value = { ...createPageState.value, isOpen: open }
1148
+ createPageState.value = { ...createPageState.value, isOpen: open, mode: 'pick' }
1149
+ }
1150
+
1151
+ export function setCreatePageMode(mode: CreatePageState['mode']): void {
1152
+ createPageState.value = { ...createPageState.value, mode }
1016
1153
  }
1017
1154
 
1018
1155
  export function setCreatingPage(creating: boolean): void {
@@ -1027,6 +1164,54 @@ export function resetCreatePageState(): void {
1027
1164
  createPageState.value = createInitialCreatePageState()
1028
1165
  }
1029
1166
 
1167
+ // ============================================================================
1168
+ // Delete Page State Mutations
1169
+ // ============================================================================
1170
+
1171
+ export function openDeletePageDialog(page: { pathname: string; title?: string }): void {
1172
+ deletePageState.value = {
1173
+ ...createInitialDeletePageState(),
1174
+ isOpen: true,
1175
+ targetPage: page,
1176
+ }
1177
+ }
1178
+
1179
+ export function setDeletePageRedirectTo(redirectTo: string): void {
1180
+ deletePageState.value = { ...deletePageState.value, redirectTo }
1181
+ }
1182
+
1183
+ export function setDeletePageCreateRedirect(createRedirect: boolean): void {
1184
+ deletePageState.value = { ...deletePageState.value, createRedirect }
1185
+ }
1186
+
1187
+ export function setDeletingPage(isDeleting: boolean): void {
1188
+ deletePageState.value = { ...deletePageState.value, isDeleting }
1189
+ }
1190
+
1191
+ export function resetDeletePageState(): void {
1192
+ deletePageState.value = createInitialDeletePageState()
1193
+ }
1194
+
1195
+ // ============================================================================
1196
+ // Redirects Manager State Mutations
1197
+ // ============================================================================
1198
+
1199
+ export function openRedirectsManager(): void {
1200
+ redirectsManagerState.value = { ...createInitialRedirectsManagerState(), isOpen: true, isLoading: true }
1201
+ }
1202
+
1203
+ export function setRedirectsManagerRules(rules: RedirectsManagerState['rules']): void {
1204
+ redirectsManagerState.value = { ...redirectsManagerState.value, rules, isLoading: false }
1205
+ }
1206
+
1207
+ export function setRedirectsManagerEditing(index: number | null): void {
1208
+ redirectsManagerState.value = { ...redirectsManagerState.value, editingIndex: index }
1209
+ }
1210
+
1211
+ export function closeRedirectsManager(): void {
1212
+ redirectsManagerState.value = createInitialRedirectsManagerState()
1213
+ }
1214
+
1030
1215
  // ============================================================================
1031
1216
  // Collections Browser State Mutations
1032
1217
  // ============================================================================
@@ -1311,6 +1496,12 @@ export function setConfig(newConfig: CmsConfig): void {
1311
1496
  config.value = newConfig
1312
1497
  }
1313
1498
 
1499
+ export function setFeatures(features: CmsConfig['features']): void {
1500
+ const current = config.value.features
1501
+ if (current?.selectElement === features?.selectElement) return
1502
+ config.value = { ...config.value, features: { ...current, ...features } }
1503
+ }
1504
+
1314
1505
  // ============================================================================
1315
1506
  // Change Navigation Mutations
1316
1507
  // ============================================================================
@@ -1389,6 +1580,8 @@ export function resetAllState(): void {
1389
1580
  markdownEditorState.value = createInitialMarkdownEditorState()
1390
1581
  mediaLibraryState.value = createInitialMediaLibraryState()
1391
1582
  createPageState.value = createInitialCreatePageState()
1583
+ deletePageState.value = createInitialDeletePageState()
1584
+ redirectsManagerState.value = createInitialRedirectsManagerState()
1392
1585
  collectionsBrowserState.value = createInitialCollectionsBrowserState()
1393
1586
  deploymentState.value = createInitialDeploymentState()
1394
1587
  colorEditorState.value = createInitialColorEditorState()
@@ -1,4 +1,4 @@
1
- import type { Attribute, CmsManifest, CollectionDefinition, ComponentInstance } from '../types'
1
+ import type { Attribute, CmsFeatures, CmsManifest, CollectionDefinition, ComponentInstance, RedirectRule } from '../types'
2
2
 
3
3
  // Re-export shared types from @nuasite/cms-marker (source of truth)
4
4
  export type {
@@ -50,6 +50,7 @@ export interface CmsConfig {
50
50
  debug: boolean
51
51
  theme?: CmsThemeConfig
52
52
  themePreset?: CmsThemePreset
53
+ features?: CmsFeatures
53
54
  }
54
55
 
55
56
  export interface ComponentProp {
@@ -379,8 +380,11 @@ export interface MediaItem {
379
380
  width?: number
380
381
  height?: number
381
382
  uploadedAt?: string
383
+ folder?: string
382
384
  }
383
385
 
386
+ export type { MediaFolderItem, MediaTypeFilter } from '../media/types'
387
+
384
388
  export interface MediaLibraryState {
385
389
  isOpen: boolean
386
390
  items: MediaItem[]
@@ -393,6 +397,37 @@ export interface CreatePageState {
393
397
  isOpen: boolean
394
398
  isCreating: boolean
395
399
  selectedCollection: string | null
400
+ mode: 'pick' | 'new' | 'duplicate' | 'collection'
401
+ }
402
+
403
+ export interface DeletePageState {
404
+ isOpen: boolean
405
+ isDeleting: boolean
406
+ targetPage: { pathname: string; title?: string } | null
407
+ redirectTo: string
408
+ createRedirect: boolean
409
+ }
410
+
411
+ // Re-export shared page/redirect types from the canonical source
412
+ export type {
413
+ AddRedirectRequest,
414
+ CreatePageRequest,
415
+ DeletePageRequest,
416
+ DeleteRedirectRequest,
417
+ DuplicatePageRequest,
418
+ GetRedirectsResponse,
419
+ LayoutInfo,
420
+ PageOperationResponse,
421
+ RedirectOperationResponse,
422
+ RedirectRule,
423
+ UpdateRedirectRequest,
424
+ } from '../types'
425
+
426
+ export interface RedirectsManagerState {
427
+ isOpen: boolean
428
+ rules: RedirectRule[]
429
+ isLoading: boolean
430
+ editingIndex: number | null
396
431
  }
397
432
 
398
433
  export interface CollectionsBrowserState {
@@ -412,6 +447,8 @@ export interface CreateMarkdownPageRequest {
412
447
  frontmatter?: Partial<BlogFrontmatter>
413
448
  /** Optional markdown content */
414
449
  content?: string
450
+ /** File extension override for data collections (e.g. 'json', 'yaml') */
451
+ fileExtension?: string
415
452
  }
416
453
 
417
454
  export interface CreateMarkdownPageResponse {
@@ -657,6 +694,20 @@ export type UndoAction =
657
694
  | UndoSeoAction
658
695
  | UndoBgImageAction
659
696
 
697
+ // ============================================================================
698
+ // MDX Component Block Types
699
+ // ============================================================================
700
+
701
+ export interface MdxPropsEditorState {
702
+ isOpen: boolean
703
+ /** ProseMirror document position of the node being edited */
704
+ nodePos: number | null
705
+ componentName: string | null
706
+ props: Record<string, string>
707
+ /** Position for the floating editor panel */
708
+ cursorPos: { x: number; y: number } | null
709
+ }
710
+
660
711
  declare global {
661
712
  interface Window {
662
713
  NuaCmsConfig?: Partial<CmsConfig>
@@ -0,0 +1,251 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import path from 'node:path'
3
+ import { scanCollections } from '../collection-scanner'
4
+ import { getProjectRoot } from '../config'
5
+ import { expectedDeletions } from '../dev-middleware'
6
+ import type { ManifestWriter } from '../manifest-writer'
7
+ import { listProjectImages } from '../media/project-images'
8
+ import type { MediaStorageAdapter } from '../media/types'
9
+ import { handleAddArrayItem, handleRemoveArrayItem } from './array-ops'
10
+ import { handleInsertComponent, handleRemoveComponent } from './component-ops'
11
+ import { handleCreateMarkdown, handleDeleteMarkdown, handleGetMarkdownContent, handleRenameMarkdown, handleUpdateMarkdown } from './markdown-ops'
12
+ import { handleCheckSlugExists, handleCreatePage, handleDeletePage, handleDuplicatePage, handleGetLayouts } from './page-ops'
13
+ import { handleAddRedirect, handleDeleteRedirect, handleGetRedirects, handleUpdateRedirect } from './redirect-ops'
14
+ import { parseJsonBody, parseMultipartFile, readBody, sendError, sendJson } from './request-utils'
15
+ import { handleUpdate } from './source-writer'
16
+
17
+ export interface RouteContext {
18
+ req: IncomingMessage
19
+ res: ServerResponse
20
+ route: string
21
+ manifestWriter: ManifestWriter
22
+ contentDir: string
23
+ mediaAdapter?: MediaStorageAdapter
24
+ }
25
+
26
+ type RouteHandler = (ctx: RouteContext) => Promise<void>
27
+
28
+ function requireMedia(ctx: RouteContext): ctx is RouteContext & { mediaAdapter: MediaStorageAdapter } {
29
+ if (!ctx.mediaAdapter) {
30
+ sendError(ctx.res, 'Media storage not configured', 501)
31
+ return false
32
+ }
33
+ return true
34
+ }
35
+
36
+ function getQuery(ctx: RouteContext): URLSearchParams {
37
+ return new URL(ctx.req.url!, `http://${ctx.req.headers.host}`).searchParams
38
+ }
39
+
40
+ // -- Route helper factories --
41
+
42
+ /** POST route: parse JSON body → handler(body, manifestWriter) → sendJson */
43
+ function post<T>(route: string, handler: (body: T, mw: ManifestWriter) => Promise<unknown>): [string, RouteHandler] {
44
+ return [`POST:${route}`, async ({ req, res, manifestWriter }) => {
45
+ const body = await parseJsonBody<T>(req)
46
+ sendJson(res, await handler(body, manifestWriter))
47
+ }]
48
+ }
49
+
50
+ /** POST route: parse JSON body → handler(body) → sendJson with success-based status */
51
+ function postWithStatus<T>(route: string, handler: (body: T) => Promise<{ success: boolean }>): [string, RouteHandler] {
52
+ return [`POST:${route}`, async ({ req, res }) => {
53
+ const body = await parseJsonBody<T>(req)
54
+ const result = await handler(body)
55
+ sendJson(res, result, result.success ? 200 : 400)
56
+ }]
57
+ }
58
+
59
+ /** GET route: handler() → sendJson */
60
+ function get(route: string, handler: () => Promise<unknown>): [string, RouteHandler] {
61
+ return [`GET:${route}`, async ({ res }) => {
62
+ sendJson(res, await handler())
63
+ }]
64
+ }
65
+
66
+ /** Custom handler for routes that don't fit the patterns above */
67
+ function custom(method: string, route: string, handler: RouteHandler): [string, RouteHandler] {
68
+ return [`${method}:${route}`, handler]
69
+ }
70
+
71
+ /** Allowed MIME types for media uploads */
72
+ const ALLOWED_UPLOAD_TYPES = new Set([
73
+ 'image/jpeg',
74
+ 'image/png',
75
+ 'image/gif',
76
+ 'image/webp',
77
+ 'image/avif',
78
+ 'image/x-icon',
79
+ 'video/mp4',
80
+ 'video/webm',
81
+ 'application/pdf',
82
+ ])
83
+
84
+ /** O(1) route lookup map: "METHOD:route" → handler */
85
+ const routeMap = new Map<string, RouteHandler>([
86
+ // Source editing
87
+ post('update', (body: Parameters<typeof handleUpdate>[0], mw) => handleUpdate(body, mw)),
88
+ post('insert-component', (body: Parameters<typeof handleInsertComponent>[0], mw) => handleInsertComponent(body, mw)),
89
+ post('remove-component', (body: Parameters<typeof handleRemoveComponent>[0], mw) => handleRemoveComponent(body, mw)),
90
+ post('add-array-item', (body: Parameters<typeof handleAddArrayItem>[0], mw) => handleAddArrayItem(body, mw)),
91
+ post('remove-array-item', (body: Parameters<typeof handleRemoveArrayItem>[0], mw) => handleRemoveArrayItem(body, mw)),
92
+
93
+ // Markdown CRUD
94
+ custom('GET', 'markdown/content', async ({ req, res }) => {
95
+ const filePath = getQuery({ req } as RouteContext).get('filePath')
96
+ if (!filePath) {
97
+ sendError(res, 'filePath query parameter required')
98
+ return
99
+ }
100
+ const result = await handleGetMarkdownContent(filePath)
101
+ if (!result) {
102
+ sendError(res, 'File not found', 404)
103
+ return
104
+ }
105
+ sendJson(res, result)
106
+ }),
107
+ custom('POST', 'markdown/update', async ({ req, res, manifestWriter }) => {
108
+ const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
109
+ sendJson(res, await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions()))
110
+ }),
111
+ post('markdown/rename', (body: Parameters<typeof handleRenameMarkdown>[0]) => handleRenameMarkdown(body)),
112
+ postWithStatus('markdown/create', (body: Parameters<typeof handleCreateMarkdown>[0]) => handleCreateMarkdown(body)),
113
+ custom('POST', 'markdown/delete', async ({ req, res, manifestWriter, contentDir }) => {
114
+ const body = await parseJsonBody<Parameters<typeof handleDeleteMarkdown>[0]>(req)
115
+ const fullPath = path.resolve(getProjectRoot(), body.filePath?.replace(/^\//, '') ?? '')
116
+ expectedDeletions.add(fullPath)
117
+ const result = await handleDeleteMarkdown(body)
118
+ if (result.success) {
119
+ manifestWriter.setCollectionDefinitions(await scanCollections(contentDir))
120
+ } else {
121
+ expectedDeletions.delete(fullPath)
122
+ }
123
+ sendJson(res, result, result.success ? 200 : 400)
124
+ }),
125
+
126
+ // Media
127
+ custom('GET', 'media/list', async (ctx) => {
128
+ if (!requireMedia(ctx)) return
129
+ const params = getQuery(ctx)
130
+ const parsedLimit = parseInt(params.get('limit') ?? '50', 10)
131
+ const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 50 : Math.min(parsedLimit, 1000)
132
+ const folder = params.get('folder') ?? undefined
133
+ sendJson(ctx.res, await ctx.mediaAdapter.list({ limit, cursor: params.get('cursor') ?? undefined, folder }))
134
+ }),
135
+ custom('GET', 'media/project-images', async (ctx) => {
136
+ const excludeDir = ctx.mediaAdapter?.staticFiles?.dir
137
+ const items = await listProjectImages({ excludeDir })
138
+ sendJson(ctx.res, { items })
139
+ }),
140
+ custom('POST', 'media/upload', async (ctx) => {
141
+ if (!requireMedia(ctx)) return
142
+ const contentType = ctx.req.headers['content-type'] ?? ''
143
+ if (!contentType.includes('multipart/form-data')) {
144
+ sendError(ctx.res, 'Expected multipart/form-data')
145
+ return
146
+ }
147
+ const folder = getQuery(ctx).get('folder') ?? undefined
148
+ const body = await readBody(ctx.req, 50 * 1024 * 1024)
149
+ const file = parseMultipartFile(body, contentType)
150
+ if (!file) {
151
+ sendError(ctx.res, 'No file found in request')
152
+ return
153
+ }
154
+ // Block SVG (can contain scripts) unless explicitly served with safe headers
155
+ if (!ALLOWED_UPLOAD_TYPES.has(file.contentType)) {
156
+ sendError(ctx.res, `File type not allowed: ${file.contentType}`)
157
+ return
158
+ }
159
+ sendJson(ctx.res, await ctx.mediaAdapter.upload(file.buffer, file.filename, file.contentType, { folder }))
160
+ }),
161
+ custom('POST', 'media/folder', async (ctx) => {
162
+ if (!requireMedia(ctx)) return
163
+ if (!ctx.mediaAdapter.createFolder) {
164
+ sendError(ctx.res, 'Folder creation not supported by this storage adapter', 501)
165
+ return
166
+ }
167
+ const body = await parseJsonBody<{ folder: string }>(ctx.req)
168
+ if (!body.folder || typeof body.folder !== 'string') {
169
+ sendError(ctx.res, 'folder field is required')
170
+ return
171
+ }
172
+ if (body.folder.includes('..')) {
173
+ sendError(ctx.res, 'Invalid folder name')
174
+ return
175
+ }
176
+ const result = await ctx.mediaAdapter.createFolder(body.folder)
177
+ sendJson(ctx.res, result, result.success ? 200 : 400)
178
+ }),
179
+
180
+ // Page operations
181
+ postWithStatus('page/create', (body: Parameters<typeof handleCreatePage>[0]) => handleCreatePage(body)),
182
+ custom('POST', 'page/duplicate', async ({ req, res }) => {
183
+ const body = await parseJsonBody<Parameters<typeof handleDuplicatePage>[0]>(req)
184
+ const result = await handleDuplicatePage(body)
185
+ if (result.success && body.createRedirect) {
186
+ await handleAddRedirect({ source: body.sourcePagePath, destination: result.url!, statusCode: 307 })
187
+ }
188
+ sendJson(res, result, result.success ? 200 : 400)
189
+ }),
190
+ custom('POST', 'page/delete', async ({ req, res }) => {
191
+ const body = await parseJsonBody<Parameters<typeof handleDeletePage>[0]>(req)
192
+ const result = await handleDeletePage(body)
193
+ if (result.success && result.filePath) {
194
+ expectedDeletions.add(path.resolve(getProjectRoot(), result.filePath))
195
+ }
196
+ if (result.success && body.createRedirect && body.redirectTo) {
197
+ await handleAddRedirect({ source: body.pagePath, destination: body.redirectTo, statusCode: 307 })
198
+ }
199
+ sendJson(res, result, result.success ? 200 : 400)
200
+ }),
201
+ custom('GET', 'page/check-slug', async (ctx) => {
202
+ const slug = getQuery(ctx).get('slug')
203
+ if (!slug) {
204
+ sendError(ctx.res, 'slug query parameter required')
205
+ return
206
+ }
207
+ sendJson(ctx.res, await handleCheckSlugExists(slug))
208
+ }),
209
+ get('page/layouts', async () => ({ layouts: await handleGetLayouts() })),
210
+
211
+ // Redirects
212
+ get('redirects', () => handleGetRedirects()),
213
+ postWithStatus('redirects/add', (body: Parameters<typeof handleAddRedirect>[0]) => handleAddRedirect(body)),
214
+ postWithStatus('redirects/update', (body: Parameters<typeof handleUpdateRedirect>[0]) => handleUpdateRedirect(body)),
215
+ postWithStatus('redirects/delete', (body: Parameters<typeof handleDeleteRedirect>[0]) => handleDeleteRedirect(body)),
216
+
217
+ // Deployment
218
+ get('deployment/status', async () => ({ currentDeployment: null, pendingCount: 0, deploymentEnabled: false })),
219
+ ])
220
+
221
+ export async function handleCmsApiRoute(
222
+ route: string,
223
+ req: IncomingMessage,
224
+ res: ServerResponse,
225
+ manifestWriter: ManifestWriter,
226
+ contentDir: string,
227
+ mediaAdapter?: MediaStorageAdapter,
228
+ ): Promise<void> {
229
+ const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter }
230
+
231
+ // Exact match lookup
232
+ const handler = routeMap.get(`${req.method}:${route}`)
233
+ if (handler) {
234
+ await handler(ctx)
235
+ return
236
+ }
237
+
238
+ // DELETE /_nua/cms/media/<id> — dynamic route with ID segment
239
+ if (req.method === 'DELETE' && route.startsWith('media/')) {
240
+ if (!requireMedia(ctx)) return
241
+ const id = route.slice('media/'.length)
242
+ if (!id || id === 'list' || id === 'upload') {
243
+ sendError(res, 'Not found', 404)
244
+ return
245
+ }
246
+ sendJson(res, await ctx.mediaAdapter!.delete(decodeURIComponent(id)))
247
+ return
248
+ }
249
+
250
+ sendError(res, 'Not found', 404)
251
+ }