@nuasite/cms 0.28.0 → 0.29.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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.28.0",
17
+ "version": "0.29.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -469,7 +469,7 @@ function parseContentConfigReferences(
469
469
  }
470
470
 
471
471
  /** Valid field type names exported by `n` helper from @nuasite/cms */
472
- const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
472
+ const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'tel', 'color', 'date', 'datetime', 'time', 'textarea'])
473
473
 
474
474
  /**
475
475
  * Parse the content config file to extract explicit field type hints:
@@ -562,17 +562,8 @@ function applyCollectionOrderBy(
562
562
  }
563
563
  }
564
564
 
565
- /**
566
- * Extract all top-level field names from a schema body string.
567
- * Matches `fieldName:` patterns at the start of lines within z.object({...}).
568
- */
569
- function extractSchemaFieldNames(schemaBody: string): Set<string> {
570
- const names = new Set<string>()
571
- for (const m of schemaBody.matchAll(/^\s*(\w+)\s*:/gm)) {
572
- names.add(m[1]!)
573
- }
574
- return names
575
- }
565
+ /** Match `fieldName:` patterns at the start of lines within a schema body. */
566
+ const SCHEMA_FIELD_PATTERN = /^\s*(\w+)\s*:/gm
576
567
 
577
568
  /**
578
569
  * When a content config schema exists, filter scanned fields to only include
@@ -586,34 +577,45 @@ function filterFieldsBySchema(
586
577
  for (const { collectionName, schemaBody } of schemaBlocks) {
587
578
  const def = collections[collectionName]
588
579
  if (!def) continue
589
- const schemaNames = extractSchemaFieldNames(schemaBody)
580
+ const schemaNames = new Set<string>()
581
+ for (const m of schemaBody.matchAll(SCHEMA_FIELD_PATTERN)) {
582
+ schemaNames.add(m[1]!)
583
+ }
590
584
  if (schemaNames.size === 0) continue
591
585
  def.fields = def.fields.filter(f => schemaNames.has(f.name))
592
586
  }
593
587
  }
594
588
 
595
589
  /**
596
- * Apply field type overrides from config parsing to scanned collections.
590
+ * Apply a parsed per-field config map to scanned collection definitions.
597
591
  */
598
- function applyConfigFieldTypes(
592
+ function applyPerFieldConfig<T>(
599
593
  collections: Record<string, CollectionDefinition>,
600
- schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
594
+ configMap: Map<string, Map<string, T>>,
595
+ apply: (field: FieldDefinition, value: T) => void,
601
596
  ): void {
602
- const configTypes = parseContentConfigFieldTypes(schemaBlocks)
603
- for (const [collectionName, fieldTypes] of configTypes) {
597
+ for (const [collectionName, fieldMap] of configMap) {
604
598
  const def = collections[collectionName]
605
599
  if (!def) continue
606
- for (const [fieldName, override] of fieldTypes) {
600
+ for (const [fieldName, value] of fieldMap) {
607
601
  const field = def.fields.find(f => f.name === fieldName)
608
602
  if (!field) continue
609
- field.type = override.type
610
- if (override.options) {
611
- field.options = override.options
612
- }
603
+ apply(field, value)
613
604
  }
614
605
  }
615
606
  }
616
607
 
608
+ /** Apply field type overrides from config parsing to scanned collections. */
609
+ function applyConfigFieldTypes(
610
+ collections: Record<string, CollectionDefinition>,
611
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
612
+ ): void {
613
+ applyPerFieldConfig(collections, parseContentConfigFieldTypes(schemaBlocks), (field, override) => {
614
+ field.type = override.type
615
+ if (override.options) field.options = override.options
616
+ })
617
+ }
618
+
617
619
  /** All recognized hint keys */
618
620
  const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
619
621
  /** Subset of hint keys that take numeric values */
@@ -672,22 +674,53 @@ function parseContentConfigFieldHints(
672
674
  }
673
675
 
674
676
  /**
675
- * Apply field hints from content config parsing to scanned collections.
677
+ * Parse required/optional status from schema blocks.
678
+ * In Zod, fields are required by default. `.optional()`, `.nullable()`, and `.default(...)` make them not required.
676
679
  */
680
+ function parseContentConfigRequiredFields(
681
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
682
+ ): Map<string, Map<string, boolean>> {
683
+ const result = new Map<string, Map<string, boolean>>()
684
+
685
+ for (const { collectionName, schemaBody } of schemaBlocks) {
686
+ const fields = new Map<string, boolean>()
687
+
688
+ const fieldMatches = [...schemaBody.matchAll(SCHEMA_FIELD_PATTERN)]
689
+ for (let i = 0; i < fieldMatches.length; i++) {
690
+ const fieldName = fieldMatches[i]![1]!
691
+ const start = fieldMatches[i]!.index!
692
+ const end = i + 1 < fieldMatches.length ? fieldMatches[i + 1]!.index! : schemaBody.length
693
+ const fieldSource = schemaBody.slice(start, end)
694
+
695
+ const isOptional = /\.(optional|nullable|default)\s*\(/.test(fieldSource)
696
+ fields.set(fieldName, !isOptional)
697
+ }
698
+
699
+ if (fields.size > 0) {
700
+ result.set(collectionName, fields)
701
+ }
702
+ }
703
+ return result
704
+ }
705
+
706
+ /** Apply field hints from content config parsing to scanned collections. */
677
707
  function applyConfigFieldHints(
678
708
  collections: Record<string, CollectionDefinition>,
679
709
  schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
680
710
  ): void {
681
- const configHints = parseContentConfigFieldHints(schemaBlocks)
682
- for (const [collectionName, fieldHints] of configHints) {
683
- const def = collections[collectionName]
684
- if (!def) continue
685
- for (const [fieldName, hints] of fieldHints) {
686
- const field = def.fields.find(f => f.name === fieldName)
687
- if (!field) continue
688
- field.hints = hints
689
- }
690
- }
711
+ applyPerFieldConfig(collections, parseContentConfigFieldHints(schemaBlocks), (field, hints) => {
712
+ field.hints = hints
713
+ })
714
+ }
715
+
716
+ /** Apply required/optional status from content config to scanned collections. */
717
+ function applyConfigRequiredFields(
718
+ collections: Record<string, CollectionDefinition>,
719
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
720
+ ): void {
721
+ applyPerFieldConfig(collections, parseContentConfigRequiredFields(schemaBlocks), (field, required) => {
722
+ field.required = required
723
+ })
691
724
  }
692
725
 
693
726
  /**
@@ -925,11 +958,12 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
925
958
  // Content directory doesn't exist or isn't readable
926
959
  }
927
960
 
928
- // Post-scan: apply explicit type hints, field hints, detect references, derived fields, and ordering
961
+ // Post-scan: apply explicit type hints, field hints, required status, detect references, derived fields, and ordering
929
962
  const schemaBlocks = await parseContentConfigSchemaBlocks()
930
963
  filterFieldsBySchema(collections, schemaBlocks)
931
964
  applyConfigFieldTypes(collections, schemaBlocks)
932
965
  applyConfigFieldHints(collections, schemaBlocks)
966
+ applyConfigRequiredFields(collections, schemaBlocks)
933
967
  await detectReferenceFields(collections, schemaBlocks)
934
968
  detectDerivedHrefFields(collections)
935
969
  applyCollectionOrderBy(collections, schemaBlocks)
@@ -2,7 +2,6 @@ import fs from 'node:fs/promises'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import path from 'node:path'
4
4
  import { getProjectRoot } from './config'
5
- import { awaitNextContentStoreUpdate } from './content-invalidator'
6
5
  import { handleCmsApiRoute } from './handlers/api-routes'
7
6
  import { buildMapPattern, detectArrayPattern, extractArrayElementProps, parseInlineArrayName } from './handlers/array-ops'
8
7
  import {
@@ -104,43 +103,6 @@ export function createDevMiddleware(
104
103
  if (options.enableCmsApi) {
105
104
  const projectRoot = getProjectRoot()
106
105
 
107
- /**
108
- * Hold the HTTP response for a `markdown/update` (or equivalent) call
109
- * until Astro's content layer has actually re-synced the edited file.
110
- *
111
- * The race we're fixing: handleUpdateMarkdown writes the file and
112
- * returns immediately, the editor then triggers a full-reload, and
113
- * the next page render reads a still-cached `astro:data-layer-content`
114
- * virtual module — so the user sees their edit disappear until Astro's
115
- * async chain (glob loader → syncData → 500 ms save debounce → atomic
116
- * write → fs.watch → invalidateModule) finally catches up.
117
- *
118
- * The fix, end to end:
119
- *
120
- * 1. `server.watcher.emit('change', fullPath)` kicks Astro's glob
121
- * loader directly. It is registered on this exact watcher (see
122
- * astro/dist/core/dev/dev.js — `viteServer.watcher` is handed to
123
- * `globalContentLayer.init`), so synthetic change events fire its
124
- * `onChange` handler and trigger `syncData`. This also works
125
- * around Vite's bundled chokidar missing some edits.
126
- * 2. `awaitNextContentStoreUpdate` parks until the shared data-store
127
- * watcher (in `vite-plugin.ts`) observes the resulting atomic
128
- * write and finishes invalidating the SSR module graph.
129
- * 3. Only then do we return — so the subsequent full-reload lands
130
- * on a page that will re-execute with fresh content.
131
- *
132
- * The timeout fallback covers edits that legitimately do not rewrite
133
- * the data store (Astro's MutableDataStore skips identical writes).
134
- * In that case no fs.watch event will ever fire, and 3 s is plenty of
135
- * budget before we give up and let the response through anyway.
136
- */
137
- const notifyContentChanged = async (filePath: string): Promise<void> => {
138
- const fullPath = path.resolve(projectRoot, filePath)
139
- const waiter = awaitNextContentStoreUpdate(3000)
140
- server.watcher?.emit('change', fullPath)
141
- await waiter
142
- }
143
-
144
106
  server.middlewares.use((req, res, next) => {
145
107
  const url = req.url || ''
146
108
  if (!url.startsWith('/_nua/cms/')) {
@@ -152,7 +114,7 @@ export function createDevMiddleware(
152
114
 
153
115
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
154
116
 
155
- handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter, notifyContentChanged)
117
+ handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
156
118
  .catch((error) => {
157
119
  console.error('[astro-cms] API error:', error)
158
120
  sendError(res, 'Internal server error', 500)
@@ -177,11 +139,31 @@ export function createDevMiddleware(
177
139
  pageMap.set(pagePath, { pathname: pagePath })
178
140
  }
179
141
 
180
- // 2. Add collection entry pages from collection definitions
142
+ // 2. Add collection entry pages from collection definitions,
143
+ // pre-populating pathnames from filesystem routes so the collections
144
+ // browser can redirect to detail pages without visiting them first.
145
+ // We build patched copies rather than mutating the originals so that
146
+ // heuristic pathnames don't persist if the route file is later removed.
181
147
  const collectionDefs = manifestWriter.getCollectionDefinitions()
182
- for (const def of Object.values(collectionDefs)) {
183
- if (def.entries) {
184
- for (const entry of def.entries) {
148
+ const collectionRoutes = await discoverCollectionRoutes()
149
+ const responseCollectionDefs: Record<string, CollectionDefinition> = {}
150
+
151
+ for (const [name, def] of Object.entries(collectionDefs)) {
152
+ const routePrefix = collectionRoutes.get(def.name)
153
+ const needsPatching = routePrefix && def.entries?.some(e => !e.pathname)
154
+
155
+ if (!needsPatching) {
156
+ responseCollectionDefs[name] = def
157
+ } else {
158
+ responseCollectionDefs[name] = {
159
+ ...def,
160
+ entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: `${routePrefix}${e.slug}` }),
161
+ }
162
+ }
163
+
164
+ const entries = responseCollectionDefs[name].entries
165
+ if (entries) {
166
+ for (const entry of entries) {
185
167
  if (entry.pathname) {
186
168
  pageMap.set(entry.pathname, { pathname: entry.pathname, title: entry.title })
187
169
  }
@@ -205,8 +187,8 @@ export function createDevMiddleware(
205
187
  availableTextStyles: manifestWriter.getAvailableTextStyles(),
206
188
  pages,
207
189
  }
208
- if (Object.keys(collectionDefs).length > 0) {
209
- manifest.collectionDefinitions = collectionDefs
190
+ if (Object.keys(responseCollectionDefs).length > 0) {
191
+ manifest.collectionDefinitions = responseCollectionDefs
210
192
  }
211
193
  const mdxComponents = manifestWriter.getMdxComponents()
212
194
  if (mdxComponents) {
@@ -626,6 +608,65 @@ async function discoverPagesFromFilesystem(): Promise<string[]> {
626
608
  return pages
627
609
  }
628
610
 
611
+ /** Cached result of collection route discovery; invalidated by file watcher */
612
+ let collectionRoutesCache: Map<string, string> | null = null
613
+
614
+ /** Invalidate the cached collection routes (called from vite-plugin when route files change) */
615
+ export function invalidateCollectionRoutesCache() {
616
+ collectionRoutesCache = null
617
+ }
618
+
619
+ /**
620
+ * Discover collection route patterns by scanning src/pages for dynamic route files
621
+ * (e.g. [slug].astro) that call getCollection(). Returns a map from collection name
622
+ * to the URL prefix (e.g. 'blog' → '/blog/'). Result is cached after first call.
623
+ */
624
+ async function discoverCollectionRoutes(): Promise<Map<string, string>> {
625
+ if (collectionRoutesCache) return collectionRoutesCache
626
+
627
+ const projectRoot = getProjectRoot()
628
+ const pagesDir = path.join(projectRoot, 'src', 'pages')
629
+ const routes = new Map<string, string>()
630
+
631
+ try {
632
+ await fs.access(pagesDir)
633
+ } catch {
634
+ collectionRoutesCache = routes
635
+ return routes
636
+ }
637
+
638
+ async function walk(dir: string, urlPrefix: string) {
639
+ const entries = await fs.readdir(dir, { withFileTypes: true })
640
+ for (const entry of entries) {
641
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
642
+
643
+ const fullPath = path.join(dir, entry.name)
644
+ if (entry.isDirectory()) {
645
+ // Skip directories with dynamic segments
646
+ if (entry.name.includes('[')) continue
647
+ await walk(fullPath, `${urlPrefix}${entry.name}/`)
648
+ } else {
649
+ const ext = path.extname(entry.name)
650
+ if (!PAGE_EXTENSIONS.has(ext)) continue
651
+ // Only interested in dynamic route files
652
+ if (!entry.name.includes('[')) continue
653
+
654
+ try {
655
+ const content = await fs.readFile(fullPath, 'utf-8')
656
+ const match = content.match(/getCollection\(\s*['"](\w+)['"]\s*\)/)
657
+ if (match?.[1]) {
658
+ routes.set(match[1], urlPrefix)
659
+ }
660
+ } catch { /* skip unreadable files */ }
661
+ }
662
+ }
663
+ }
664
+
665
+ await walk(pagesDir, '/')
666
+ collectionRoutesCache = routes
667
+ return routes
668
+ }
669
+
629
670
  function mediaMimeFromExt(ext: string): string {
630
671
  const map: Record<string, string> = {
631
672
  '.jpg': 'image/jpeg',
@@ -11,7 +11,6 @@ import {
11
11
  selectBrowserCollection,
12
12
  selectedBrowserCollection,
13
13
  } from '../signals'
14
- import { savePendingEntryNavigation } from '../storage'
15
14
  import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
16
15
  import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
17
16
 
@@ -51,16 +50,9 @@ export function CollectionsBrowser() {
51
50
  const def = selectedDef
52
51
  if (!def) return null
53
52
 
54
- const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
53
+ const handleEntryClick = (slug: string, sourcePath: string) => {
55
54
  closeCollectionsBrowser()
56
- if (pathname) {
57
- // Navigate to the collection detail page to edit inline.
58
- savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
59
- window.location.href = pathname
60
- } else {
61
- // No detail page exists for this entry — open the markdown editor inline.
62
- openMarkdownEditorForEntry(selected, slug, sourcePath, def)
63
- }
55
+ openMarkdownEditorForEntry(selected, slug, sourcePath, def)
64
56
  }
65
57
 
66
58
  const handleAddNew = () => {
@@ -201,7 +193,7 @@ export function CollectionsBrowser() {
201
193
  : (
202
194
  <button
203
195
  type="button"
204
- onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
196
+ onClick={() => handleEntryClick(entry.slug, entry.sourcePath)}
205
197
  class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
206
198
  data-cms-ui
207
199
  >
@@ -206,7 +206,12 @@ function NewPageForm() {
206
206
  }
207
207
 
208
208
  return (
209
- <>
209
+ <form
210
+ onSubmit={(e) => {
211
+ e.preventDefault()
212
+ handleSubmit()
213
+ }}
214
+ >
210
215
  <ModalHeader title="New Blank Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
211
216
  <div class="p-5 space-y-4">
212
217
  <Field label="Title">
@@ -215,6 +220,7 @@ function NewPageForm() {
215
220
  value={form.title}
216
221
  onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
217
222
  placeholder="My New Page"
223
+ required
218
224
  class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
219
225
  autoFocus
220
226
  data-cms-ui
@@ -229,6 +235,7 @@ function NewPageForm() {
229
235
  value={form.slug}
230
236
  onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
231
237
  placeholder="my-new-page"
238
+ required
232
239
  class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
233
240
  data-cms-ui
234
241
  />
@@ -253,16 +260,15 @@ function NewPageForm() {
253
260
  <ModalFooter>
254
261
  <CancelButton onClick={() => resetCreatePageState()} />
255
262
  <button
256
- type="button"
257
- onClick={handleSubmit}
258
- disabled={!canSubmit}
263
+ type="submit"
264
+ disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
259
265
  class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
260
266
  data-cms-ui
261
267
  >
262
268
  Create Page
263
269
  </button>
264
270
  </ModalFooter>
265
- </>
271
+ </form>
266
272
  )
267
273
  }
268
274
 
@@ -302,7 +308,12 @@ function DuplicatePageForm() {
302
308
  }
303
309
 
304
310
  return (
305
- <>
311
+ <form
312
+ onSubmit={(e) => {
313
+ e.preventDefault()
314
+ handleSubmit()
315
+ }}
316
+ >
306
317
  <ModalHeader title="Duplicate Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
307
318
  <div class="p-5 space-y-4">
308
319
  <Field label="Source Page">
@@ -312,6 +323,7 @@ function DuplicatePageForm() {
312
323
  setSourcePath((e.target as HTMLSelectElement).value)
313
324
  form.resetSlugManual()
314
325
  }}
326
+ required
315
327
  class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
316
328
  data-cms-ui
317
329
  >
@@ -342,6 +354,7 @@ function DuplicatePageForm() {
342
354
  value={form.slug}
343
355
  onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
344
356
  placeholder="new-page-slug"
357
+ required
345
358
  class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
346
359
  data-cms-ui
347
360
  />
@@ -363,16 +376,15 @@ function DuplicatePageForm() {
363
376
  <ModalFooter>
364
377
  <CancelButton onClick={() => resetCreatePageState()} />
365
378
  <button
366
- type="button"
367
- onClick={handleSubmit}
368
- disabled={!canSubmit}
379
+ type="submit"
380
+ disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
369
381
  class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
370
382
  data-cms-ui
371
383
  >
372
384
  Duplicate Page
373
385
  </button>
374
386
  </ModalFooter>
375
- </>
387
+ </form>
376
388
  )
377
389
  }
378
390
 
@@ -45,9 +45,12 @@ export interface TextFieldProps {
45
45
  isDirty?: boolean
46
46
  onReset?: () => void
47
47
  inputType?: string
48
+ required?: boolean
48
49
  }
49
50
 
50
- export function TextField({ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
51
+ export function TextField(
52
+ { label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
53
+ ) {
51
54
  return (
52
55
  <div class="space-y-1.5">
53
56
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -57,6 +60,7 @@ export function TextField({ label, value, placeholder, maxLength, minLength, onC
57
60
  placeholder={placeholder}
58
61
  maxLength={maxLength}
59
62
  minLength={minLength}
63
+ required={required}
60
64
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
61
65
  class={cn(
62
66
  'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -82,9 +86,10 @@ export interface ImageFieldProps {
82
86
  onBrowse: () => void
83
87
  isDirty?: boolean
84
88
  onReset?: () => void
89
+ required?: boolean
85
90
  }
86
91
 
87
- export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
92
+ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
88
93
  const hasImage = !!value && value.length > 0
89
94
 
90
95
  return (
@@ -114,6 +119,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
114
119
  type="text"
115
120
  value={value ?? ''}
116
121
  placeholder={placeholder}
122
+ required={required}
117
123
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
118
124
  class={cn(
119
125
  'flex-1 min-w-0 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -147,9 +153,10 @@ export interface ColorFieldProps {
147
153
  onChange: (value: string) => void
148
154
  isDirty?: boolean
149
155
  onReset?: () => void
156
+ required?: boolean
150
157
  }
151
158
 
152
- export function ColorField({ label, value, placeholder, onChange, isDirty, onReset }: ColorFieldProps) {
159
+ export function ColorField({ label, value, placeholder, onChange, isDirty, onReset, required }: ColorFieldProps) {
153
160
  const colorValue = value || '#000000'
154
161
  // Validate hex for the native picker (must be #rrggbb)
155
162
  const isValidHex = /^#[0-9a-fA-F]{6}$/.test(colorValue)
@@ -170,6 +177,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
170
177
  type="text"
171
178
  value={value ?? ''}
172
179
  placeholder={placeholder ?? '#000000'}
180
+ required={required}
173
181
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
174
182
  class={cn(
175
183
  'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -278,9 +286,10 @@ export interface NumberFieldProps {
278
286
  onChange: (value: number | undefined) => void
279
287
  isDirty?: boolean
280
288
  onReset?: () => void
289
+ required?: boolean
281
290
  }
282
291
 
283
- export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset }: NumberFieldProps) {
292
+ export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
284
293
  return (
285
294
  <div class="space-y-1.5">
286
295
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -291,6 +300,7 @@ export function NumberField({ label, value, placeholder, min, max, step, onChang
291
300
  min={min}
292
301
  max={max}
293
302
  step={step}
303
+ required={required}
294
304
  onInput={(e) => {
295
305
  const val = (e.target as HTMLInputElement).value
296
306
  onChange(val === '' ? undefined : Number(val))
@@ -336,9 +346,10 @@ export interface ComboBoxFieldProps {
336
346
  onChange: (value: string) => void
337
347
  isDirty?: boolean
338
348
  onReset?: () => void
349
+ required?: boolean
339
350
  }
340
351
 
341
- export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset }: ComboBoxFieldProps) {
352
+ export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset, required }: ComboBoxFieldProps) {
342
353
  const [query, setQuery] = useState('')
343
354
  const [isOpen, setIsOpen] = useState(false)
344
355
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
@@ -378,6 +389,13 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
378
389
  }, [onChange])
379
390
 
380
391
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
392
+ if (e.key === 'Enter') {
393
+ e.preventDefault()
394
+ if (isOpen && highlightedIndex >= 0 && filtered[highlightedIndex]) {
395
+ selectOption(filtered[highlightedIndex]!.value)
396
+ }
397
+ return
398
+ }
381
399
  if (!isOpen || filtered.length === 0) return
382
400
  if (e.key === 'ArrowDown') {
383
401
  e.preventDefault()
@@ -385,9 +403,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
385
403
  } else if (e.key === 'ArrowUp') {
386
404
  e.preventDefault()
387
405
  setHighlightedIndex(i => Math.max(i - 1, 0))
388
- } else if (e.key === 'Enter' && highlightedIndex >= 0) {
389
- e.preventDefault()
390
- selectOption(filtered[highlightedIndex]!.value)
391
406
  } else if (e.key === 'Escape') {
392
407
  setIsOpen(false)
393
408
  }
@@ -411,6 +426,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
411
426
  type="text"
412
427
  value={value ?? ''}
413
428
  placeholder={placeholder}
429
+ required={required}
414
430
  onInput={handleInput}
415
431
  onFocus={handleFocus}
416
432
  onBlur={handleBlur}
@@ -394,7 +394,7 @@ export function SchemaFrontmatterField({
394
394
  value,
395
395
  onChange,
396
396
  }: SchemaFrontmatterFieldProps) {
397
- const label = formatFieldLabel(field.name)
397
+ const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
398
398
  const hints = field.hints
399
399
 
400
400
  switch (field.type) {
@@ -410,6 +410,7 @@ export function SchemaFrontmatterField({
410
410
  minLength={hints?.minLength as number | undefined}
411
411
  onChange={(v) => onChange(v)}
412
412
  inputType={field.type === 'text' ? undefined : field.type}
413
+ required={field.required}
413
414
  />
414
415
  )
415
416
 
@@ -425,6 +426,7 @@ export function SchemaFrontmatterField({
425
426
  onChange(url)
426
427
  })
427
428
  }}
429
+ required={field.required}
428
430
  />
429
431
  )
430
432
 
@@ -435,6 +437,7 @@ export function SchemaFrontmatterField({
435
437
  value={(value as string) ?? ''}
436
438
  placeholder={getPlaceholder(field)}
437
439
  onChange={(v) => onChange(v)}
440
+ required={field.required}
438
441
  />
439
442
  )
440
443
 
@@ -448,6 +451,7 @@ export function SchemaFrontmatterField({
448
451
  placeholder={hints?.placeholder ?? getPlaceholder(field)}
449
452
  rows={hints?.rows ?? 3}
450
453
  maxLength={hints?.maxLength as number | undefined}
454
+ required={field.required}
451
455
  class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
452
456
  data-cms-ui
453
457
  />
@@ -465,6 +469,7 @@ export function SchemaFrontmatterField({
465
469
  value={(value as string) ?? ''}
466
470
  min={hints?.min != null ? String(hints.min) : undefined}
467
471
  max={hints?.max != null ? String(hints.max) : undefined}
472
+ required={field.required}
468
473
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
469
474
  class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
470
475
  data-cms-ui
@@ -482,6 +487,7 @@ export function SchemaFrontmatterField({
482
487
  max={typeof hints?.max === 'number' ? hints.max : undefined}
483
488
  step={hints?.step}
484
489
  onChange={(v) => onChange(v ?? 0)}
490
+ required={field.required}
485
491
  />
486
492
  )
487
493
 
@@ -505,6 +511,7 @@ export function SchemaFrontmatterField({
505
511
  label: opt,
506
512
  }))}
507
513
  onChange={(v) => onChange(v)}
514
+ required={field.required}
508
515
  />
509
516
  )
510
517
 
@@ -517,6 +524,7 @@ export function SchemaFrontmatterField({
517
524
  placeholder={`Select ${label.toLowerCase()}...`}
518
525
  options={refOptions}
519
526
  onChange={(v) => onChange(v)}
527
+ required={field.required}
520
528
  />
521
529
  )
522
530
  }