@nuasite/cms 0.27.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.27.0",
17
+ "version": "0.29.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
4
4
  import { getProjectRoot } from './config'
5
5
  import { slugifyHref } from './shared'
6
- import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
6
+ import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldHints, FieldType } from './types'
7
7
 
8
8
  /** Regex patterns for type inference */
9
9
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
@@ -31,7 +31,6 @@ const SIDEBAR_FIELD_NAMES = new Set([
31
31
  'author',
32
32
  ])
33
33
 
34
- /** Directive pattern: # @position <value> or # @group <value> */
35
34
  /** Matches `@position <value>` or `@group <value>` in YAML comment text (# already stripped by parser) */
36
35
  const DIRECTIVE_PATTERN = /^\s*@(position|group)\s+(.+)$/
37
36
 
@@ -400,10 +399,6 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
400
399
  const fullPath = path.join(projectRoot, configPath)
401
400
  const content = await fs.readFile(fullPath, 'utf-8')
402
401
 
403
- const collectionBlocks = content.matchAll(
404
- /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*z\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g,
405
- )
406
-
407
402
  // Map variable names to collection names from exports
408
403
  const varToName = new Map<string, string>()
409
404
  const exportMatch = content.match(/export\s+const\s+collections\s*=\s*\{([\s\S]*?)\}/)
@@ -414,13 +409,32 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
414
409
  }
415
410
  }
416
411
 
412
+ // Find schema block starts via regex, then extract bodies with brace counting
413
+ // to correctly handle nested objects like n.number({ min: 1, max: 100 })
414
+ const schemaStart = /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*(?:z|n)\.object\s*\(\s*\{/g
417
415
  const blocks: Array<{ collectionName: string; schemaBody: string }> = []
418
- for (const block of collectionBlocks) {
419
- const varName = block[1]
420
- const schemaBody = block[2]!
416
+
417
+ let match
418
+ while ((match = schemaStart.exec(content)) !== null) {
419
+ const varName = match[1]
421
420
  const collectionName = varName ? varToName.get(varName) : undefined
422
421
  if (!collectionName) continue
423
- blocks.push({ collectionName, schemaBody })
422
+
423
+ // Brace-balanced extraction: the regex consumed the opening {,
424
+ // so start at depth 1 and scan forward for the matching }
425
+ const bodyStart = match.index + match[0].length
426
+ let depth = 1
427
+ let i = bodyStart
428
+ while (i < content.length && depth > 0) {
429
+ if (content[i] === '{') depth++
430
+ else if (content[i] === '}') depth--
431
+ i++
432
+ }
433
+
434
+ if (depth === 0) {
435
+ // i is one past the matching }, so body is [bodyStart, i-1)
436
+ blocks.push({ collectionName, schemaBody: content.slice(bodyStart, i - 1) })
437
+ }
424
438
  }
425
439
 
426
440
  if (blocks.length > 0) return blocks
@@ -455,7 +469,7 @@ function parseContentConfigReferences(
455
469
  }
456
470
 
457
471
  /** Valid field type names exported by `n` helper from @nuasite/cms */
458
- const FIELD_HELPER_TYPES = new Set(['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'])
459
473
 
460
474
  /**
461
475
  * Parse the content config file to extract explicit field type hints:
@@ -501,17 +515,56 @@ function parseContentConfigFieldTypes(
501
515
  }
502
516
 
503
517
  /**
504
- * Extract all top-level field names from a schema body string.
505
- * Matches `fieldName:` patterns at the start of lines within z.object({...}).
518
+ * Parse the content config to find `.orderBy('asc'|'desc')` markers on fields.
519
+ * Matches patterns like `fieldName: n.number().orderBy('asc')`.
520
+ * Returns a map: collectionName → { field, direction }.
521
+ */
522
+ function parseContentConfigOrderBy(
523
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
524
+ ): Map<string, { field: string; direction: 'asc' | 'desc' }> {
525
+ const result = new Map<string, { field: string; direction: 'asc' | 'desc' }>()
526
+ for (const { collectionName, schemaBody } of schemaBlocks) {
527
+ const match = schemaBody.match(/(\w+)\s*:.*\.orderBy\s*\(\s*(?:['"](\w+)['"])?\s*\)/)
528
+ if (match) {
529
+ const direction = match[2] === 'desc' ? 'desc' as const : 'asc' as const
530
+ result.set(collectionName, { field: match[1]!, direction })
531
+ }
532
+ }
533
+ return result
534
+ }
535
+
536
+ /**
537
+ * Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries.
506
538
  */
507
- function extractSchemaFieldNames(schemaBody: string): Set<string> {
508
- const names = new Set<string>()
509
- for (const m of schemaBody.matchAll(/^\s*(\w+)\s*:/gm)) {
510
- names.add(m[1]!)
539
+ function applyCollectionOrderBy(
540
+ collections: Record<string, CollectionDefinition>,
541
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
542
+ ): void {
543
+ const orderByFields = parseContentConfigOrderBy(schemaBlocks)
544
+ for (const [collectionName, { field: fieldName, direction }] of orderByFields) {
545
+ const def = collections[collectionName]
546
+ if (!def) continue
547
+ def.orderBy = fieldName
548
+ def.orderDirection = direction
549
+ if (def.entries && def.entries.length > 1) {
550
+ const dir = direction === 'desc' ? -1 : 1
551
+ def.entries.sort((a, b) => {
552
+ const aVal = a.data?.[fieldName]
553
+ const bVal = b.data?.[fieldName]
554
+ if (aVal == null && bVal == null) return 0
555
+ if (aVal == null) return 1
556
+ if (bVal == null) return -1
557
+ if (typeof aVal === 'number' && typeof bVal === 'number') return (aVal - bVal) * dir
558
+ if (aVal instanceof Date && bVal instanceof Date) return (aVal.getTime() - bVal.getTime()) * dir
559
+ return String(aVal).localeCompare(String(bVal)) * dir
560
+ })
561
+ }
511
562
  }
512
- return names
513
563
  }
514
564
 
565
+ /** Match `fieldName:` patterns at the start of lines within a schema body. */
566
+ const SCHEMA_FIELD_PATTERN = /^\s*(\w+)\s*:/gm
567
+
515
568
  /**
516
569
  * When a content config schema exists, filter scanned fields to only include
517
570
  * those defined in the schema. This prevents stale or extra frontmatter fields
@@ -524,32 +577,150 @@ function filterFieldsBySchema(
524
577
  for (const { collectionName, schemaBody } of schemaBlocks) {
525
578
  const def = collections[collectionName]
526
579
  if (!def) continue
527
- 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
+ }
528
584
  if (schemaNames.size === 0) continue
529
585
  def.fields = def.fields.filter(f => schemaNames.has(f.name))
530
586
  }
531
587
  }
532
588
 
533
589
  /**
534
- * Apply field type overrides from config parsing to scanned collections.
590
+ * Apply a parsed per-field config map to scanned collection definitions.
535
591
  */
536
- function applyConfigFieldTypes(
592
+ function applyPerFieldConfig<T>(
537
593
  collections: Record<string, CollectionDefinition>,
538
- schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
594
+ configMap: Map<string, Map<string, T>>,
595
+ apply: (field: FieldDefinition, value: T) => void,
539
596
  ): void {
540
- const configTypes = parseContentConfigFieldTypes(schemaBlocks)
541
- for (const [collectionName, fieldTypes] of configTypes) {
597
+ for (const [collectionName, fieldMap] of configMap) {
542
598
  const def = collections[collectionName]
543
599
  if (!def) continue
544
- for (const [fieldName, override] of fieldTypes) {
600
+ for (const [fieldName, value] of fieldMap) {
545
601
  const field = def.fields.find(f => f.name === fieldName)
546
602
  if (!field) continue
547
- field.type = override.type
548
- if (override.options) {
549
- field.options = override.options
603
+ apply(field, value)
604
+ }
605
+ }
606
+ }
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
+
619
+ /** All recognized hint keys */
620
+ const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
621
+ /** Subset of hint keys that take numeric values */
622
+ const NUMERIC_HINT_KEYS = new Set(['min', 'max', 'step', 'maxLength', 'minLength', 'rows'])
623
+
624
+ /**
625
+ * Parse `n.type({ key: value, ... })` options objects from schema blocks.
626
+ * Returns a map: collectionName → fieldName → FieldHints.
627
+ */
628
+ function parseContentConfigFieldHints(
629
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
630
+ ): Map<string, Map<string, FieldHints>> {
631
+ const result = new Map<string, Map<string, FieldHints>>()
632
+
633
+ for (const { collectionName, schemaBody } of schemaBlocks) {
634
+ const fields = new Map<string, FieldHints>()
635
+
636
+ // Match: fieldName: n.helperName({ ...options })
637
+ const fieldMatches = schemaBody.matchAll(/(\w+)\s*:\s*n\.\w+\s*\(\s*\{([\s\S]*?)}\s*\)/g)
638
+ for (const m of fieldMatches) {
639
+ const fieldName = m[1]!
640
+ const optionsBody = m[2]!
641
+ const raw: Record<string, string | number> = {}
642
+
643
+ // Extract key-value pairs from the options body
644
+ const kvMatches = optionsBody.matchAll(/(\w+)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?[\d.]+))/g)
645
+ for (const kv of kvMatches) {
646
+ const key = kv[1]!
647
+ if (!VALID_HINT_KEYS.has(key)) continue
648
+ const strValue = kv[2] ?? kv[3]
649
+ const numValue = kv[4]
650
+
651
+ if (numValue != null && NUMERIC_HINT_KEYS.has(key)) {
652
+ raw[key] = Number(numValue)
653
+ } else if (strValue != null) {
654
+ if (NUMERIC_HINT_KEYS.has(key)) {
655
+ const parsed = Number(strValue)
656
+ raw[key] = Number.isNaN(parsed) ? strValue : parsed
657
+ } else {
658
+ raw[key] = strValue
659
+ }
660
+ }
661
+ }
662
+ const hints = raw as FieldHints
663
+
664
+ if (Object.keys(hints).length > 0) {
665
+ fields.set(fieldName, hints)
550
666
  }
551
667
  }
668
+
669
+ if (fields.size > 0) {
670
+ result.set(collectionName, fields)
671
+ }
552
672
  }
673
+ return result
674
+ }
675
+
676
+ /**
677
+ * Parse required/optional status from schema blocks.
678
+ * In Zod, fields are required by default. `.optional()`, `.nullable()`, and `.default(...)` make them not required.
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. */
707
+ function applyConfigFieldHints(
708
+ collections: Record<string, CollectionDefinition>,
709
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
710
+ ): void {
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
+ })
553
724
  }
554
725
 
555
726
  /**
@@ -787,12 +958,15 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
787
958
  // Content directory doesn't exist or isn't readable
788
959
  }
789
960
 
790
- // Post-scan: apply explicit type hints, detect references, and derived fields
961
+ // Post-scan: apply explicit type hints, field hints, required status, detect references, derived fields, and ordering
791
962
  const schemaBlocks = await parseContentConfigSchemaBlocks()
792
963
  filterFieldsBySchema(collections, schemaBlocks)
793
964
  applyConfigFieldTypes(collections, schemaBlocks)
965
+ applyConfigFieldHints(collections, schemaBlocks)
966
+ applyConfigRequiredFields(collections, schemaBlocks)
794
967
  await detectReferenceFields(collections, schemaBlocks)
795
968
  detectDerivedHrefFields(collections)
969
+ applyCollectionOrderBy(collections, schemaBlocks)
796
970
 
797
971
  return collections
798
972
  }
@@ -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