@nuasite/cms 0.18.1 → 0.19.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 +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
package/src/editor/signals.ts
CHANGED
|
@@ -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()
|
package/src/editor/types.ts
CHANGED
|
@@ -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
|
+
}
|