@nuasite/cms 0.20.4 → 0.21.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.
- package/dist/editor.js +16886 -16613
- package/package.json +1 -1
- package/src/collection-scanner.ts +106 -20
- package/src/dev-middleware.ts +4 -0
- package/src/editor/api.ts +2 -0
- package/src/editor/components/fields.tsx +3 -2
- package/src/editor/components/frontmatter-fields.tsx +31 -20
- package/src/editor/components/markdown-editor-overlay.tsx +20 -3
- package/src/editor/components/prop-editor.tsx +353 -26
- package/src/editor/components/reference-picker.tsx +3 -13
- package/src/editor/components/seo-editor.tsx +0 -2
- package/src/editor/constants.ts +0 -13
- package/src/editor/editor.ts +1 -4
- package/src/editor/hooks/useBlockEditorHandlers.ts +1 -5
- package/src/editor/manifest.ts +10 -0
- package/src/editor/signals.ts +11 -0
- package/src/field-types.ts +42 -0
- package/src/handlers/markdown-ops.ts +13 -1
- package/src/index.ts +11 -0
- package/src/manifest-writer.ts +7 -0
- package/src/prop-types.ts +46 -0
- package/src/types.ts +4 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { slugify } from '../../shared'
|
|
3
|
+
import { getCollectionEntryOptions } from '../manifest'
|
|
4
|
+
import { manifest, openMediaLibraryWithCallback, pendingCollectionEntries } from '../signals'
|
|
1
5
|
import type { ComponentProp } from '../types'
|
|
6
|
+
import { SchemaFrontmatterField } from './frontmatter-fields'
|
|
2
7
|
|
|
3
8
|
export interface PropEditorProps {
|
|
4
9
|
prop: ComponentProp
|
|
@@ -6,10 +11,145 @@ export interface PropEditorProps {
|
|
|
6
11
|
onChange: (value: string) => void
|
|
7
12
|
}
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Parse a union of string literals like `'left' | 'right' | 'center'` into an array of options.
|
|
16
|
+
* Returns null if the type is not a pure string-literal union.
|
|
17
|
+
*/
|
|
18
|
+
function parseStringLiteralUnion(type: string): string[] | null {
|
|
19
|
+
const parts = type.split('|').map(s => s.trim())
|
|
20
|
+
const values: string[] = []
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
const match = part.match(/^['"](.+)['"]$/)
|
|
23
|
+
if (!match) return null
|
|
24
|
+
values.push(match[1]!)
|
|
25
|
+
}
|
|
26
|
+
return values.length > 0 ? values : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse Reference<'collectionName'> and return the collection name, or null.
|
|
31
|
+
*/
|
|
32
|
+
function parseReference(type: string): string | null {
|
|
33
|
+
const match = type.match(/^Reference\s*<\s*['"](\w+)['"]\s*>$/)
|
|
34
|
+
return match?.[1] ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INPUT_TYPES: Record<string, string> = { number: 'number', url: 'url', date: 'date', datetime: 'datetime-local', time: 'time', email: 'email' }
|
|
38
|
+
|
|
39
|
+
function renderPropInput(prop: ComponentProp, value: string, onChange: (value: string) => void) {
|
|
40
|
+
const typeLower = prop.type.toLowerCase()
|
|
41
|
+
const unionOptions = parseStringLiteralUnion(prop.type)
|
|
42
|
+
const referenceCollection = parseReference(prop.type)
|
|
43
|
+
|
|
44
|
+
if (typeLower === 'boolean') {
|
|
45
|
+
return (
|
|
46
|
+
<label class="flex items-center gap-2 cursor-pointer">
|
|
47
|
+
<input
|
|
48
|
+
type="checkbox"
|
|
49
|
+
checked={value === 'true'}
|
|
50
|
+
onChange={(e) => onChange((e.target as HTMLInputElement).checked ? 'true' : 'false')}
|
|
51
|
+
class="accent-cms-primary w-5 h-5 rounded"
|
|
52
|
+
/>
|
|
53
|
+
<span class="text-[13px] text-white">
|
|
54
|
+
{value === 'true' ? 'Enabled' : 'Disabled'}
|
|
55
|
+
</span>
|
|
56
|
+
</label>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (referenceCollection) {
|
|
61
|
+
return <ReferenceSelect collection={referenceCollection} value={value} required={prop.required} onChange={onChange} />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (unionOptions) {
|
|
65
|
+
return (
|
|
66
|
+
<select
|
|
67
|
+
value={value}
|
|
68
|
+
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
|
69
|
+
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
70
|
+
>
|
|
71
|
+
{!prop.required && <option value="">— None —</option>}
|
|
72
|
+
{unionOptions.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
73
|
+
</select>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeLower === 'image') {
|
|
78
|
+
return (
|
|
79
|
+
<div class="flex gap-2">
|
|
80
|
+
<input
|
|
81
|
+
type="text"
|
|
82
|
+
value={value}
|
|
83
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
84
|
+
placeholder={prop.defaultValue || 'Select an image...'}
|
|
85
|
+
class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
86
|
+
/>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={() => {
|
|
90
|
+
openMediaLibraryWithCallback((url: string) => {
|
|
91
|
+
onChange(url)
|
|
92
|
+
})
|
|
93
|
+
}}
|
|
94
|
+
class="px-3 py-2.5 bg-white/10 border border-white/20 text-white/70 hover:text-white hover:bg-white/15 rounded-cms-md transition-colors shrink-0"
|
|
95
|
+
title="Browse media"
|
|
96
|
+
>
|
|
97
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
98
|
+
<path
|
|
99
|
+
stroke-linecap="round"
|
|
100
|
+
stroke-linejoin="round"
|
|
101
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
102
|
+
/>
|
|
103
|
+
</svg>
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
12
108
|
|
|
109
|
+
if (typeLower === 'color') {
|
|
110
|
+
return (
|
|
111
|
+
<div class="flex gap-2 items-center">
|
|
112
|
+
<input
|
|
113
|
+
type="color"
|
|
114
|
+
value={value || '#000000'}
|
|
115
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
116
|
+
class="w-10 h-10 rounded-cms-md border border-white/20 bg-transparent cursor-pointer"
|
|
117
|
+
/>
|
|
118
|
+
<input
|
|
119
|
+
type="text"
|
|
120
|
+
value={value}
|
|
121
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
122
|
+
placeholder={prop.defaultValue || '#000000'}
|
|
123
|
+
class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md font-mono"
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeLower === 'textarea') {
|
|
130
|
+
return (
|
|
131
|
+
<textarea
|
|
132
|
+
value={value}
|
|
133
|
+
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
|
134
|
+
placeholder={prop.defaultValue || `Enter ${prop.name}...`}
|
|
135
|
+
rows={3}
|
|
136
|
+
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md resize-y"
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<input
|
|
143
|
+
type={INPUT_TYPES[typeLower] ?? 'text'}
|
|
144
|
+
value={value}
|
|
145
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
146
|
+
placeholder={prop.defaultValue || `Enter ${prop.name}...`}
|
|
147
|
+
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function PropEditor({ prop, value, onChange }: PropEditorProps) {
|
|
13
153
|
return (
|
|
14
154
|
<div class="mb-4">
|
|
15
155
|
<label class="block text-[13px] font-medium text-white mb-1.5">
|
|
@@ -21,32 +161,219 @@ export function PropEditor({ prop, value, onChange }: PropEditorProps) {
|
|
|
21
161
|
{prop.description}
|
|
22
162
|
</div>
|
|
23
163
|
)}
|
|
24
|
-
{
|
|
25
|
-
? (
|
|
26
|
-
<label class="flex items-center gap-2 cursor-pointer">
|
|
27
|
-
<input
|
|
28
|
-
type="checkbox"
|
|
29
|
-
checked={value === 'true'}
|
|
30
|
-
onChange={(e) => onChange((e.target as HTMLInputElement).checked ? 'true' : 'false')}
|
|
31
|
-
class="accent-cms-primary w-5 h-5 rounded"
|
|
32
|
-
/>
|
|
33
|
-
<span class="text-[13px] text-white">
|
|
34
|
-
{value === 'true' ? 'Enabled' : 'Disabled'}
|
|
35
|
-
</span>
|
|
36
|
-
</label>
|
|
37
|
-
)
|
|
38
|
-
: (
|
|
39
|
-
<input
|
|
40
|
-
type={isNumber ? 'number' : 'text'}
|
|
41
|
-
value={value}
|
|
42
|
-
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
43
|
-
placeholder={prop.defaultValue || `Enter ${prop.name}...`}
|
|
44
|
-
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
45
|
-
/>
|
|
46
|
-
)}
|
|
164
|
+
{renderPropInput(prop, value, onChange)}
|
|
47
165
|
<div class="text-[10px] text-white/40 mt-1.5 font-mono">
|
|
48
166
|
{prop.type}
|
|
49
167
|
</div>
|
|
50
168
|
</div>
|
|
51
169
|
)
|
|
52
170
|
}
|
|
171
|
+
|
|
172
|
+
function ReferenceSelect({ collection, value, required, onChange }: {
|
|
173
|
+
collection: string
|
|
174
|
+
value: string
|
|
175
|
+
required: boolean
|
|
176
|
+
onChange: (value: string) => void
|
|
177
|
+
}) {
|
|
178
|
+
const currentManifest = manifest.value
|
|
179
|
+
const options = useMemo(
|
|
180
|
+
() => currentManifest ? getCollectionEntryOptions(currentManifest, collection) : [],
|
|
181
|
+
[collection, currentManifest],
|
|
182
|
+
)
|
|
183
|
+
const collectionDef = currentManifest?.collectionDefinitions?.[collection]
|
|
184
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
185
|
+
const [search, setSearch] = useState('')
|
|
186
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
187
|
+
const [isCreating, setIsCreating] = useState(false)
|
|
188
|
+
const [newName, setNewName] = useState('')
|
|
189
|
+
const [formData, setFormData] = useState<Record<string, unknown>>({})
|
|
190
|
+
|
|
191
|
+
const filtered = useMemo(
|
|
192
|
+
() =>
|
|
193
|
+
search
|
|
194
|
+
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()) || o.value.toLowerCase().includes(search.toLowerCase()))
|
|
195
|
+
: options,
|
|
196
|
+
[options, search],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const selectedLabel = useMemo(
|
|
200
|
+
() => value ? (options.find(o => o.value === value)?.label ?? value) : '',
|
|
201
|
+
[options, value],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const formFields = useMemo(
|
|
205
|
+
() => collectionDef?.fields.filter(f => !f.hidden && f.name !== 'title' && f.name !== 'name') ?? [],
|
|
206
|
+
[collectionDef],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const resetCreateForm = () => {
|
|
210
|
+
setIsCreating(false)
|
|
211
|
+
setNewName('')
|
|
212
|
+
setFormData({})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const handleCreate = () => {
|
|
216
|
+
if (!collectionDef || !newName.trim()) return
|
|
217
|
+
const slug = slugify(newName.trim())
|
|
218
|
+
// Queue entry for creation when markdown is saved — no file write, no reload
|
|
219
|
+
pendingCollectionEntries.value = [
|
|
220
|
+
...pendingCollectionEntries.value,
|
|
221
|
+
{
|
|
222
|
+
collection,
|
|
223
|
+
slug,
|
|
224
|
+
title: newName.trim(),
|
|
225
|
+
frontmatter: { ...formData },
|
|
226
|
+
fileExtension: collectionDef.fileExtension,
|
|
227
|
+
},
|
|
228
|
+
]
|
|
229
|
+
onChange(slug)
|
|
230
|
+
resetCreateForm()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isCreating) {
|
|
234
|
+
const slug = slugify(newName.trim())
|
|
235
|
+
return (
|
|
236
|
+
<div class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3">
|
|
237
|
+
<div class="flex items-center justify-between">
|
|
238
|
+
<span class="text-[12px] font-medium text-white/70">Create new entry</span>
|
|
239
|
+
{options.length > 0 && (
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={resetCreateForm}
|
|
243
|
+
class="text-[11px] text-white/40 hover:text-white transition-colors"
|
|
244
|
+
>
|
|
245
|
+
Select existing
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
<input
|
|
250
|
+
type="text"
|
|
251
|
+
value={newName}
|
|
252
|
+
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
|
253
|
+
placeholder="Enter name..."
|
|
254
|
+
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
255
|
+
autoFocus
|
|
256
|
+
/>
|
|
257
|
+
<div class="text-[11px] text-white/40 font-mono">
|
|
258
|
+
src/content/{collection}/{slug || 'your-slug'}.{collectionDef?.fileExtension ?? 'json'}
|
|
259
|
+
</div>
|
|
260
|
+
{/* Collection fields */}
|
|
261
|
+
{formFields.length > 0 && (
|
|
262
|
+
<div class="space-y-3 pt-1 border-t border-white/10">
|
|
263
|
+
{formFields.map((field) => (
|
|
264
|
+
<SchemaFrontmatterField
|
|
265
|
+
key={field.name}
|
|
266
|
+
field={field}
|
|
267
|
+
value={formData[field.name]}
|
|
268
|
+
onChange={(newValue) => setFormData(prev => ({ ...prev, [field.name]: newValue }))}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
<div class="flex gap-2 pt-1">
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
onClick={resetCreateForm}
|
|
277
|
+
class="px-3 py-1.5 text-[12px] text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-md transition-colors"
|
|
278
|
+
>
|
|
279
|
+
Cancel
|
|
280
|
+
</button>
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
onClick={handleCreate}
|
|
284
|
+
disabled={!newName.trim()}
|
|
285
|
+
class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
286
|
+
>
|
|
287
|
+
Create
|
|
288
|
+
</button>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (options.length === 0 && !collectionDef) {
|
|
295
|
+
return (
|
|
296
|
+
<input
|
|
297
|
+
type="text"
|
|
298
|
+
value={value}
|
|
299
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
300
|
+
placeholder={`Enter ${collection} entry ID...`}
|
|
301
|
+
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div class="relative" ref={containerRef}>
|
|
308
|
+
<input
|
|
309
|
+
type="text"
|
|
310
|
+
value={isOpen ? search : selectedLabel}
|
|
311
|
+
onInput={(e) => {
|
|
312
|
+
setSearch((e.target as HTMLInputElement).value)
|
|
313
|
+
setIsOpen(true)
|
|
314
|
+
}}
|
|
315
|
+
onFocus={() => setIsOpen(true)}
|
|
316
|
+
onBlur={(e) => {
|
|
317
|
+
const related = (e as FocusEvent).relatedTarget as Node | null
|
|
318
|
+
if (containerRef.current && related && containerRef.current.contains(related)) return
|
|
319
|
+
setIsOpen(false)
|
|
320
|
+
}}
|
|
321
|
+
placeholder={`Select ${collection} entry...`}
|
|
322
|
+
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
323
|
+
/>
|
|
324
|
+
{isOpen && (
|
|
325
|
+
<div class="absolute z-10 mt-1 w-full max-h-48 overflow-y-auto bg-cms-dark border border-white/20 rounded-cms-md shadow-lg">
|
|
326
|
+
{!required && (
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
330
|
+
onClick={() => {
|
|
331
|
+
onChange('')
|
|
332
|
+
setSearch('')
|
|
333
|
+
setIsOpen(false)
|
|
334
|
+
}}
|
|
335
|
+
class="w-full px-4 py-2 text-left text-[13px] text-white/50 hover:bg-white/10 transition-colors"
|
|
336
|
+
>
|
|
337
|
+
— None —
|
|
338
|
+
</button>
|
|
339
|
+
)}
|
|
340
|
+
{filtered.map((opt) => (
|
|
341
|
+
<button
|
|
342
|
+
key={opt.value}
|
|
343
|
+
type="button"
|
|
344
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
345
|
+
onClick={() => {
|
|
346
|
+
onChange(opt.value)
|
|
347
|
+
setSearch('')
|
|
348
|
+
setIsOpen(false)
|
|
349
|
+
}}
|
|
350
|
+
class={`w-full px-4 py-2 text-left text-[13px] transition-colors ${
|
|
351
|
+
opt.value === value ? 'bg-cms-primary/20 text-white' : 'text-white/80 hover:bg-white/10'
|
|
352
|
+
}`}
|
|
353
|
+
>
|
|
354
|
+
<div>{opt.label}</div>
|
|
355
|
+
{opt.label !== opt.value && <div class="text-[11px] text-white/40 font-mono">{opt.value}</div>}
|
|
356
|
+
</button>
|
|
357
|
+
))}
|
|
358
|
+
{filtered.length === 0 && <div class="px-4 py-2 text-[13px] text-white/40">No entries found</div>}
|
|
359
|
+
{collectionDef && (
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
363
|
+
onClick={() => {
|
|
364
|
+
setIsCreating(true)
|
|
365
|
+
setIsOpen(false)
|
|
366
|
+
}}
|
|
367
|
+
class="w-full px-4 py-2 text-left text-[13px] text-cms-primary hover:bg-cms-primary/10 transition-colors border-t border-white/10 flex items-center gap-2"
|
|
368
|
+
>
|
|
369
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
370
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
371
|
+
</svg>
|
|
372
|
+
Create new {collectionDef.label?.toLowerCase() ?? collection}
|
|
373
|
+
</button>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
-
import { clampPanelPosition,
|
|
2
|
+
import { clampPanelPosition, Z_INDEX } from '../constants'
|
|
3
|
+
import { getCollectionEntryOptions } from '../manifest'
|
|
3
4
|
import { updateMarkdownPage } from '../markdown-api'
|
|
4
5
|
import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
|
|
5
6
|
|
|
6
7
|
const PANEL_WIDTH = 320
|
|
7
8
|
|
|
8
|
-
function getCollectionEntryOptions(collectionName?: string): Array<{ value: string; label: string }> {
|
|
9
|
-
if (!collectionName) return []
|
|
10
|
-
const def = manifest.value.collectionDefinitions?.[collectionName]
|
|
11
|
-
if (!def?.entries) return []
|
|
12
|
-
return def.entries.map(e => ({
|
|
13
|
-
value: e.slug,
|
|
14
|
-
label: e.title ?? e.slug,
|
|
15
|
-
}))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
9
|
export function ReferencePicker() {
|
|
19
10
|
const state = referencePickerState.value
|
|
20
11
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
@@ -23,7 +14,7 @@ export function ReferencePicker() {
|
|
|
23
14
|
const [saving, setSaving] = useState(false)
|
|
24
15
|
|
|
25
16
|
const options = useMemo(
|
|
26
|
-
() => getCollectionEntryOptions(state.collection ?? undefined),
|
|
17
|
+
() => manifest.value ? getCollectionEntryOptions(manifest.value, state.collection ?? undefined) : [],
|
|
27
18
|
[state.collection],
|
|
28
19
|
)
|
|
29
20
|
|
|
@@ -57,7 +48,6 @@ export function ReferencePicker() {
|
|
|
57
48
|
})
|
|
58
49
|
if (result.success) {
|
|
59
50
|
showToast('Reference updated', 'success')
|
|
60
|
-
schedulePageReload()
|
|
61
51
|
} else {
|
|
62
52
|
showToast(result.error || 'Failed to update reference', 'error')
|
|
63
53
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useState } from 'preact/hooks'
|
|
2
2
|
import { saveBatchChanges } from '../api'
|
|
3
|
-
import { schedulePageReload } from '../constants'
|
|
4
3
|
import { isApplyingUndoRedo, recordChange } from '../history'
|
|
5
4
|
import {
|
|
6
5
|
clearPendingSeoChanges,
|
|
@@ -227,7 +226,6 @@ export function SeoEditor() {
|
|
|
227
226
|
showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
|
|
228
227
|
clearPendingSeoChanges()
|
|
229
228
|
closeSeoEditor()
|
|
230
|
-
schedulePageReload()
|
|
231
229
|
}
|
|
232
230
|
} catch (error) {
|
|
233
231
|
showToast(error instanceof Error ? error.message : 'Failed to save SEO changes', 'error')
|
package/src/editor/constants.ts
CHANGED
|
@@ -43,21 +43,8 @@ export const TIMING = {
|
|
|
43
43
|
PREVIEW_ERROR_DURATION_MS: 5000,
|
|
44
44
|
/** Delay before focusing input after expansion (ms) */
|
|
45
45
|
FOCUS_DELAY_MS: 50,
|
|
46
|
-
/** Delay before reloading the page after a content-modifying save (ms) */
|
|
47
|
-
RELOAD_DELAY_MS: 100,
|
|
48
|
-
/** Longer reload delay to allow collapse animation to play (ms) */
|
|
49
|
-
RELOAD_COLLAPSE_DELAY_MS: 300,
|
|
50
46
|
} as const
|
|
51
47
|
|
|
52
|
-
/**
|
|
53
|
-
* Schedule a page reload after a content-modifying save.
|
|
54
|
-
* In normal dev, Vite HMR (via chokidar) usually reloads the page before this fires.
|
|
55
|
-
* In sandboxed environments (e.g. E2B) where HMR is unavailable, this ensures the page still refreshes.
|
|
56
|
-
*/
|
|
57
|
-
export function schedulePageReload(delayMs: number = TIMING.RELOAD_DELAY_MS) {
|
|
58
|
-
setTimeout(() => location.reload(), delayMs)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
48
|
/**
|
|
62
49
|
* Layout constants for UI positioning
|
|
63
50
|
*/
|
package/src/editor/editor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fetchManifest, getMarkdownContent, saveBatchChanges } from './api'
|
|
2
|
-
import { CSS,
|
|
2
|
+
import { CSS, TIMING } from './constants'
|
|
3
3
|
import {
|
|
4
4
|
cleanupHighlightSystem,
|
|
5
5
|
disableAllInteractiveElements,
|
|
@@ -877,9 +877,6 @@ export async function saveAllChanges(
|
|
|
877
877
|
}
|
|
878
878
|
|
|
879
879
|
onStateChange?.()
|
|
880
|
-
|
|
881
|
-
schedulePageReload()
|
|
882
|
-
|
|
883
880
|
return { success: true, updated: result.updated }
|
|
884
881
|
} catch (err) {
|
|
885
882
|
console.error('[CMS] Save failed:', err)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCallback, useState } from 'preact/hooks'
|
|
2
|
-
import { schedulePageReload, TIMING } from '../constants'
|
|
3
2
|
import { logDebug } from '../dom'
|
|
4
3
|
import { getComponentInstances } from '../manifest'
|
|
5
4
|
import * as signals from '../signals'
|
|
@@ -153,7 +152,6 @@ export function useBlockEditorHandlers({
|
|
|
153
152
|
}
|
|
154
153
|
|
|
155
154
|
showToast(`Item added ${position} current item`, 'success')
|
|
156
|
-
schedulePageReload()
|
|
157
155
|
} else {
|
|
158
156
|
// Standard component insertion
|
|
159
157
|
const response = await fetch(`${config.apiBase}/insert-component`, {
|
|
@@ -178,7 +176,6 @@ export function useBlockEditorHandlers({
|
|
|
178
176
|
}
|
|
179
177
|
|
|
180
178
|
showToast(`${componentName} inserted ${position} component`, 'success')
|
|
181
|
-
schedulePageReload()
|
|
182
179
|
}
|
|
183
180
|
} catch (error) {
|
|
184
181
|
console.error('[CMS] Failed to insert component:', error)
|
|
@@ -237,11 +234,10 @@ export function useBlockEditorHandlers({
|
|
|
237
234
|
|
|
238
235
|
showToast(arrayMode ? 'Item removed' : 'Component removed', 'success')
|
|
239
236
|
|
|
240
|
-
// Visually collapse the component
|
|
237
|
+
// Visually collapse and hide the component until HMR refreshes the page
|
|
241
238
|
if (componentEl) {
|
|
242
239
|
collapseElement(componentEl)
|
|
243
240
|
}
|
|
244
|
-
schedulePageReload(TIMING.RELOAD_COLLAPSE_DELAY_MS)
|
|
245
241
|
} catch (error) {
|
|
246
242
|
console.error('[CMS] Failed to remove component:', error)
|
|
247
243
|
showToast(arrayMode ? 'Failed to remove item' : 'Failed to remove component', 'error')
|
package/src/editor/manifest.ts
CHANGED
|
@@ -23,3 +23,13 @@ export const getManifestEntryCount: GetManifestEntryCount = (manifest: CmsManife
|
|
|
23
23
|
|
|
24
24
|
type GetAvailableComponentNames = (manifest: CmsManifest) => string[]
|
|
25
25
|
export const getAvailableComponentNames: GetAvailableComponentNames = manifest => Object.keys(getComponentDefinitions(manifest))
|
|
26
|
+
|
|
27
|
+
export function getCollectionEntryOptions(manifest: CmsManifest, collectionName?: string): Array<{ value: string; label: string }> {
|
|
28
|
+
if (!collectionName) return []
|
|
29
|
+
const def = manifest.collectionDefinitions?.[collectionName]
|
|
30
|
+
if (!def?.entries) return []
|
|
31
|
+
return def.entries.map(e => ({
|
|
32
|
+
value: e.slug,
|
|
33
|
+
label: e.title ?? e.slug,
|
|
34
|
+
}))
|
|
35
|
+
}
|
package/src/editor/signals.ts
CHANGED
|
@@ -244,6 +244,16 @@ export const pendingColorChanges = signal<Map<string, PendingColorChange>>(
|
|
|
244
244
|
export const pendingBgImageChanges = signal<Map<string, PendingBackgroundImageChange>>(
|
|
245
245
|
new Map(),
|
|
246
246
|
)
|
|
247
|
+
/** Pending collection entries to create when the markdown page is saved */
|
|
248
|
+
export interface PendingCollectionEntry {
|
|
249
|
+
collection: string
|
|
250
|
+
slug: string
|
|
251
|
+
title: string
|
|
252
|
+
frontmatter: Record<string, unknown>
|
|
253
|
+
fileExtension?: string
|
|
254
|
+
}
|
|
255
|
+
export const pendingCollectionEntries = signal<PendingCollectionEntry[]>([])
|
|
256
|
+
|
|
247
257
|
export const manifest = signal<CmsManifest>({
|
|
248
258
|
entries: {},
|
|
249
259
|
components: {},
|
|
@@ -810,6 +820,7 @@ export function updateMarkdownPageMeta(patch: Partial<Pick<MarkdownPageEntry, 's
|
|
|
810
820
|
|
|
811
821
|
export function resetMarkdownEditorState(): void {
|
|
812
822
|
markdownEditorState.value = createInitialMarkdownEditorState()
|
|
823
|
+
pendingCollectionEntries.value = []
|
|
813
824
|
}
|
|
814
825
|
|
|
815
826
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic field type wrappers for Zod schemas in content collections.
|
|
3
|
+
*
|
|
4
|
+
* These are identity functions — they return exactly what's passed in.
|
|
5
|
+
* The CMS collection scanner detects them by name in the source and
|
|
6
|
+
* renders the appropriate editor input.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { field } from '@nuasite/cms'
|
|
11
|
+
* import { z } from 'astro/zod'
|
|
12
|
+
*
|
|
13
|
+
* const schema = z.object({
|
|
14
|
+
* photo: field.image(z.string()),
|
|
15
|
+
* website: field.url(z.string()),
|
|
16
|
+
* contact: field.email(z.string()),
|
|
17
|
+
* accent: field.color(z.string()),
|
|
18
|
+
* publishedAt: field.date(z.string()),
|
|
19
|
+
* startsAt: field.datetime(z.string()),
|
|
20
|
+
* opensAt: field.time(z.string()),
|
|
21
|
+
* bio: field.textarea(z.string()),
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export const field = {
|
|
26
|
+
/** Image picker (opens media library) */
|
|
27
|
+
image: <T>(schema: T): T => schema,
|
|
28
|
+
/** URL input */
|
|
29
|
+
url: <T>(schema: T): T => schema,
|
|
30
|
+
/** Email input */
|
|
31
|
+
email: <T>(schema: T): T => schema,
|
|
32
|
+
/** Color picker */
|
|
33
|
+
color: <T>(schema: T): T => schema,
|
|
34
|
+
/** Date picker */
|
|
35
|
+
date: <T>(schema: T): T => schema,
|
|
36
|
+
/** Date + time picker */
|
|
37
|
+
datetime: <T>(schema: T): T => schema,
|
|
38
|
+
/** Time picker */
|
|
39
|
+
time: <T>(schema: T): T => schema,
|
|
40
|
+
/** Multiline textarea */
|
|
41
|
+
textarea: <T>(schema: T): T => schema,
|
|
42
|
+
}
|
|
@@ -308,8 +308,20 @@ function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>;
|
|
|
308
308
|
return { frontmatter, content }
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/** Pattern for strings that YAML auto-parses as Date objects */
|
|
312
|
+
const YAML_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
|
|
313
|
+
|
|
311
314
|
function serializeFrontmatter(frontmatter: Record<string, unknown>, content: string): string {
|
|
312
|
-
const
|
|
315
|
+
const doc = new yaml.Document(frontmatter)
|
|
316
|
+
// Force-quote strings that YAML would auto-parse as dates
|
|
317
|
+
yaml.visit(doc, {
|
|
318
|
+
Scalar(_key, node) {
|
|
319
|
+
if (typeof node.value === 'string' && YAML_DATE_PATTERN.test(node.value)) {
|
|
320
|
+
node.type = yaml.Scalar.QUOTE_SINGLE
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
const yamlStr = doc.toString().trim()
|
|
313
325
|
return `---\n${yamlStr}\n---\n${content}`
|
|
314
326
|
}
|
|
315
327
|
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
56
56
|
collections?: Record<string, {
|
|
57
57
|
fields?: Record<string, { position?: 'sidebar' | 'header'; group?: string }>
|
|
58
58
|
}>
|
|
59
|
+
/**
|
|
60
|
+
* Enable polling for file watching.
|
|
61
|
+
* Ensures reliable change detection after CMS edits.
|
|
62
|
+
* Set to `false` to use native fs events instead.
|
|
63
|
+
* @default true
|
|
64
|
+
*/
|
|
65
|
+
usePolling?: boolean
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
|
|
@@ -78,6 +85,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
78
85
|
componentDirs = ['src/components'],
|
|
79
86
|
contentDir = 'src/content',
|
|
80
87
|
mdxComponentDirs,
|
|
88
|
+
usePolling = true,
|
|
81
89
|
seo = { trackSeo: true, markTitle: true, parseJsonLd: true },
|
|
82
90
|
} = options
|
|
83
91
|
|
|
@@ -277,6 +285,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
277
285
|
: undefined,
|
|
278
286
|
server: {
|
|
279
287
|
proxy: proxyConfig,
|
|
288
|
+
...(usePolling ? { watch: { usePolling: true } } : {}),
|
|
280
289
|
},
|
|
281
290
|
},
|
|
282
291
|
})
|
|
@@ -337,10 +346,12 @@ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void })
|
|
|
337
346
|
logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
|
|
338
347
|
}
|
|
339
348
|
|
|
349
|
+
export { field } from './field-types'
|
|
340
350
|
export { createContemberStorageAdapter as contemberMedia } from './media/contember'
|
|
341
351
|
export { createLocalStorageAdapter as localMedia } from './media/local'
|
|
342
352
|
export { createS3StorageAdapter as s3Media } from './media/s3'
|
|
343
353
|
export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
|
|
354
|
+
export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Url } from './prop-types'
|
|
344
355
|
|
|
345
356
|
export { scanCollections } from './collection-scanner'
|
|
346
357
|
export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
|