@nuasite/cms 0.20.5 → 0.22.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.
@@ -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
- export function PropEditor({ prop, value, onChange }: PropEditorProps) {
10
- const isBoolean = prop.type === 'boolean'
11
- const isNumber = prop.type === 'number'
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
- {isBoolean
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, schedulePageReload, Z_INDEX } from '../constants'
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')
@@ -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
  */
@@ -1,5 +1,5 @@
1
1
  import { fetchManifest, getMarkdownContent, saveBatchChanges } from './api'
2
- import { CSS, schedulePageReload, TIMING } from './constants'
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 then reload to pick up file changes
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')
@@ -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
+ }
@@ -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 yamlStr = yaml.stringify(frontmatter).trim()
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'