@nuasite/cms 0.20.5 → 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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.20.5",
17
+ "version": "0.21.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -389,11 +389,10 @@ async function scanCollection(collectionPath: string, collectionName: string, co
389
389
  }
390
390
 
391
391
  /**
392
- * Parse the Astro content config file to extract explicit reference() declarations.
393
- * Returns a map: collectionName { fieldName { target, isArray } }
392
+ * Read and parse the Astro content config file, extracting schema blocks for each collection.
393
+ * Returns parsed blocks with collection names and their raw schema bodies.
394
394
  */
395
- async function parseContentConfigReferences(): Promise<Map<string, Map<string, { target: string; isArray: boolean }>>> {
396
- const result = new Map<string, Map<string, { target: string; isArray: boolean }>>()
395
+ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName: string; schemaBody: string }>> {
397
396
  const projectRoot = getProjectRoot()
398
397
 
399
398
  for (const configPath of ['src/content/config.ts', 'src/content.config.ts']) {
@@ -401,7 +400,6 @@ async function parseContentConfigReferences(): Promise<Map<string, Map<string, {
401
400
  const fullPath = path.join(projectRoot, configPath)
402
401
  const content = await fs.readFile(fullPath, 'utf-8')
403
402
 
404
- // Parse defineCollection blocks to extract schema bodies
405
403
  const collectionBlocks = content.matchAll(
406
404
  /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*z\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g,
407
405
  )
@@ -416,39 +414,125 @@ async function parseContentConfigReferences(): Promise<Map<string, Map<string, {
416
414
  }
417
415
  }
418
416
 
417
+ const blocks: Array<{ collectionName: string; schemaBody: string }> = []
419
418
  for (const block of collectionBlocks) {
420
419
  const varName = block[1]
421
420
  const schemaBody = block[2]!
422
421
  const collectionName = varName ? varToName.get(varName) : undefined
423
422
  if (!collectionName) continue
424
-
425
- const fields = new Map<string, { target: string; isArray: boolean }>()
426
- const fieldRefs = schemaBody.matchAll(/(\w+)\s*:\s*(z\.array\s*\(\s*)?reference\s*\(\s*['"](\w+)['"]\s*\)/g)
427
- for (const m of fieldRefs) {
428
- fields.set(m[1]!, { target: m[3]!, isArray: !!m[2] })
429
- }
430
-
431
- if (fields.size > 0) {
432
- result.set(collectionName, fields)
433
- }
423
+ blocks.push({ collectionName, schemaBody })
434
424
  }
435
425
 
436
- if (result.size > 0) break // Found a config file with references
426
+ if (blocks.length > 0) return blocks
437
427
  } catch {
438
428
  // File doesn't exist, try next
439
429
  }
440
430
  }
431
+ return []
432
+ }
433
+
434
+ /**
435
+ * Parse the Astro content config file to extract explicit reference() declarations.
436
+ * Returns a map: collectionName → { fieldName → { target, isArray } }
437
+ */
438
+ function parseContentConfigReferences(
439
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
440
+ ): Map<string, Map<string, { target: string; isArray: boolean }>> {
441
+ const result = new Map<string, Map<string, { target: string; isArray: boolean }>>()
442
+
443
+ for (const { collectionName, schemaBody } of schemaBlocks) {
444
+ const fields = new Map<string, { target: string; isArray: boolean }>()
445
+ const fieldRefs = schemaBody.matchAll(/(\w+)\s*:\s*(z\.array\s*\(\s*)?reference\s*\(\s*['"](\w+)['"]\s*\)/g)
446
+ for (const m of fieldRefs) {
447
+ fields.set(m[1]!, { target: m[3]!, isArray: !!m[2] })
448
+ }
449
+
450
+ if (fields.size > 0) {
451
+ result.set(collectionName, fields)
452
+ }
453
+ }
441
454
  return result
442
455
  }
443
456
 
457
+ /** Valid field type names exported by `field` helper from @nuasite/cms */
458
+ const FIELD_HELPER_TYPES = new Set(['image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
459
+
460
+ /**
461
+ * Parse the content config file to extract explicit field type hints:
462
+ * - `field.image(...)`, `field.url(...)`, etc. from @nuasite/cms
463
+ * - `z.enum([...])` for select options
464
+ *
465
+ * Returns a map: collectionName → fieldName → { type, options? }
466
+ */
467
+ function parseContentConfigFieldTypes(
468
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
469
+ ): Map<string, Map<string, { type: FieldType; options?: string[] }>> {
470
+ const result = new Map<string, Map<string, { type: FieldType; options?: string[] }>>()
471
+
472
+ for (const { collectionName, schemaBody } of schemaBlocks) {
473
+ const fields = new Map<string, { type: FieldType; options?: string[] }>()
474
+
475
+ // Detect field.image(...), field.url(...), etc.
476
+ const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*field\.(\w+)\s*\(/g)
477
+ for (const m of fieldHelpers) {
478
+ const fieldName = m[1]!
479
+ const helperName = m[2]!
480
+ if (FIELD_HELPER_TYPES.has(helperName)) {
481
+ fields.set(fieldName, { type: helperName as FieldType })
482
+ }
483
+ }
484
+
485
+ // Detect z.enum(['a', 'b', 'c'])
486
+ const enumFields = schemaBody.matchAll(/(\w+)\s*:\s*z\.enum\s*\(\s*\[([\s\S]*?)\]\s*\)/g)
487
+ for (const m of enumFields) {
488
+ const fieldName = m[1]!
489
+ const enumBody = m[2]!
490
+ const options = [...enumBody.matchAll(/['"]([^'"]+)['"]/g)].map(o => o[1]!)
491
+ if (options.length > 0) {
492
+ fields.set(fieldName, { type: 'select', options })
493
+ }
494
+ }
495
+
496
+ if (fields.size > 0) {
497
+ result.set(collectionName, fields)
498
+ }
499
+ }
500
+ return result
501
+ }
502
+
503
+ /**
504
+ * Apply field type overrides from config parsing to scanned collections.
505
+ */
506
+ function applyConfigFieldTypes(
507
+ collections: Record<string, CollectionDefinition>,
508
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
509
+ ): void {
510
+ const configTypes = parseContentConfigFieldTypes(schemaBlocks)
511
+ for (const [collectionName, fieldTypes] of configTypes) {
512
+ const def = collections[collectionName]
513
+ if (!def) continue
514
+ for (const [fieldName, override] of fieldTypes) {
515
+ const field = def.fields.find(f => f.name === fieldName)
516
+ if (!field) continue
517
+ field.type = override.type
518
+ if (override.options) {
519
+ field.options = override.options
520
+ }
521
+ }
522
+ }
523
+ }
524
+
444
525
  /**
445
526
  * After all collections are scanned, detect reference fields.
446
527
  * Prefers explicit reference() declarations from the content config file.
447
528
  * Falls back to heuristic slug matching when no config is available.
448
529
  */
449
- async function detectReferenceFields(collections: Record<string, CollectionDefinition>): Promise<void> {
530
+ async function detectReferenceFields(
531
+ collections: Record<string, CollectionDefinition>,
532
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
533
+ ): Promise<void> {
450
534
  // Try parsing the content config first — this is the source of truth
451
- const configRefs = await parseContentConfigReferences()
535
+ const configRefs = parseContentConfigReferences(schemaBlocks)
452
536
  if (configRefs.size > 0) {
453
537
  for (const [collectionName, fieldRefs] of configRefs) {
454
538
  const def = collections[collectionName]
@@ -673,8 +757,10 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
673
757
  // Content directory doesn't exist or isn't readable
674
758
  }
675
759
 
676
- // Post-scan: detect cross-collection references and derived fields
677
- await detectReferenceFields(collections)
760
+ // Post-scan: apply explicit type hints, detect references, and derived fields
761
+ const schemaBlocks = await parseContentConfigSchemaBlocks()
762
+ applyConfigFieldTypes(collections, schemaBlocks)
763
+ await detectReferenceFields(collections, schemaBlocks)
678
764
  detectDerivedHrefFields(collections)
679
765
 
680
766
  return collections
@@ -44,9 +44,6 @@ interface ViteDevServerLike {
44
44
  on: (event: string, listener: (...args: any[]) => void) => any
45
45
  removeListener: (event: string, listener: (...args: any[]) => void) => any
46
46
  }
47
- environments?: Record<string, {
48
- moduleGraph: { invalidateAll: () => void }
49
- }>
50
47
  }
51
48
 
52
49
  /**
@@ -115,17 +112,6 @@ export function createDevMiddleware(
115
112
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
116
113
 
117
114
  handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
118
- .then(() => {
119
- // Invalidate all Vite environment module caches after content-modifying
120
- // routes so that a subsequent page reload serves fresh content.
121
- // In sandboxed environments (e.g. E2B) chokidar doesn't detect file
122
- // changes, leaving stale modules in Astro's SSR/prerender environments.
123
- if (req.method === 'POST' && server.environments) {
124
- for (const env of Object.values(server.environments)) {
125
- env.moduleGraph.invalidateAll()
126
- }
127
- }
128
- })
129
115
  .catch((error) => {
130
116
  console.error('[astro-cms] API error:', error)
131
117
  sendError(res, 'Internal server error', 500)
@@ -181,6 +167,10 @@ export function createDevMiddleware(
181
167
  if (Object.keys(collectionDefs).length > 0) {
182
168
  manifest.collectionDefinitions = collectionDefs
183
169
  }
170
+ const mdxComponents = manifestWriter.getMdxComponents()
171
+ if (mdxComponents) {
172
+ manifest.mdxComponents = mdxComponents
173
+ }
184
174
  res.end(JSON.stringify(manifest, null, 2))
185
175
  return
186
176
  }
package/src/editor/api.ts CHANGED
@@ -91,6 +91,8 @@ export async function fetchManifest(): Promise<CmsManifest> {
91
91
  metadata: pageManifest?.metadata,
92
92
  // SEO data from page-specific manifest
93
93
  seo: pageManifest?.seo,
94
+ // MDX component allowlist from global manifest
95
+ mdxComponents: globalManifest?.mdxComponents,
94
96
  } as CmsManifest
95
97
  }
96
98
 
@@ -42,14 +42,15 @@ export interface TextFieldProps {
42
42
  onChange: (value: string) => void
43
43
  isDirty?: boolean
44
44
  onReset?: () => void
45
+ inputType?: string
45
46
  }
46
47
 
47
- export function TextField({ label, value, placeholder, onChange, isDirty, onReset }: TextFieldProps) {
48
+ export function TextField({ label, value, placeholder, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
48
49
  return (
49
50
  <div class="space-y-1.5">
50
51
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
51
52
  <input
52
- type="text"
53
+ type={inputType}
53
54
  value={value ?? ''}
54
55
  placeholder={placeholder}
55
56
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
@@ -1,5 +1,6 @@
1
1
  import type { ComponentChildren } from 'preact'
2
2
  import { useEffect, useState } from 'preact/hooks'
3
+ import { getCollectionEntryOptions } from '../manifest'
3
4
  import { renameMarkdownPage } from '../markdown-api'
4
5
  import {
5
6
  config,
@@ -11,7 +12,7 @@ import {
11
12
  updateMarkdownPageMeta,
12
13
  } from '../signals'
13
14
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
14
- import { ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
15
+ import { ColorField, ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
15
16
  import { groupFields } from './frontmatter-sidebar'
16
17
 
17
18
  function isArrayOfObjects(value: unknown[]): value is Record<string, unknown>[] {
@@ -188,7 +189,9 @@ export function CreateModeFrontmatter({
188
189
  fields,
189
190
  onSlugManualEdit,
190
191
  }: CreateModeFrontmatterProps) {
191
- const displayFields = fields ?? collectionDefinition.fields
192
+ const allFields = fields ?? collectionDefinition.fields
193
+ // In create mode, skip complex fields (arrays, objects) — they can be edited after creation
194
+ const displayFields = allFields.filter(f => f.type !== 'array' && f.type !== 'object')
192
195
  const groups = groupFields(displayFields)
193
196
 
194
197
  return (
@@ -376,20 +379,6 @@ export function EditModeFrontmatter({
376
379
  )
377
380
  }
378
381
 
379
- // ============================================================================
380
- // Collection Reference Helpers
381
- // ============================================================================
382
-
383
- function getCollectionEntryOptions(collectionName?: string): Array<{ value: string; label: string }> {
384
- if (!collectionName) return []
385
- const def = manifest.value.collectionDefinitions?.[collectionName]
386
- if (!def?.entries) return []
387
- return def.entries.map(e => ({
388
- value: e.slug,
389
- label: e.title ?? e.slug,
390
- }))
391
- }
392
-
393
382
  // ============================================================================
394
383
  // Schema-aware Frontmatter Field
395
384
  // ============================================================================
@@ -410,12 +399,14 @@ export function SchemaFrontmatterField({
410
399
  switch (field.type) {
411
400
  case 'text':
412
401
  case 'url':
402
+ case 'email':
413
403
  return (
414
404
  <TextField
415
405
  label={label}
416
406
  value={(value as string) ?? ''}
417
407
  placeholder={getPlaceholder(field)}
418
408
  onChange={(v) => onChange(v)}
409
+ inputType={field.type === 'text' ? undefined : field.type}
419
410
  />
420
411
  )
421
412
 
@@ -434,6 +425,16 @@ export function SchemaFrontmatterField({
434
425
  />
435
426
  )
436
427
 
428
+ case 'color':
429
+ return (
430
+ <ColorField
431
+ label={label}
432
+ value={(value as string) ?? ''}
433
+ placeholder={getPlaceholder(field)}
434
+ onChange={(v) => onChange(v)}
435
+ />
436
+ )
437
+
437
438
  case 'textarea':
438
439
  return (
439
440
  <div class="flex flex-col gap-1 col-span-2" data-cms-ui>
@@ -450,11 +451,13 @@ export function SchemaFrontmatterField({
450
451
  )
451
452
 
452
453
  case 'date':
454
+ case 'datetime':
455
+ case 'time':
453
456
  return (
454
457
  <div class="flex flex-col gap-1" data-cms-ui>
455
458
  <label class="text-xs text-white/60 font-medium">{label}</label>
456
459
  <input
457
- type="date"
460
+ type={field.type === 'datetime' ? 'datetime-local' : field.type}
458
461
  value={(value as string) ?? ''}
459
462
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
460
463
  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"
@@ -496,7 +499,7 @@ export function SchemaFrontmatterField({
496
499
  )
497
500
 
498
501
  case 'reference': {
499
- const refOptions = getCollectionEntryOptions(field.collection)
502
+ const refOptions = getCollectionEntryOptions(manifest.value, field.collection)
500
503
  return (
501
504
  <ComboBoxField
502
505
  label={label}
@@ -512,7 +515,7 @@ export function SchemaFrontmatterField({
512
515
  const items = Array.isArray(value) ? value : []
513
516
  // Array of references — show multiselect with collection entries
514
517
  if (field.itemType === 'reference' && field.collection) {
515
- const refEntries = getCollectionEntryOptions(field.collection)
518
+ const refEntries = getCollectionEntryOptions(manifest.value, field.collection)
516
519
  return (
517
520
  <div class="col-span-2" data-cms-ui>
518
521
  <MultiSelectField
@@ -536,7 +539,7 @@ export function SchemaFrontmatterField({
536
539
  </div>
537
540
  )
538
541
  }
539
- if (isArrayOfObjects(items)) {
542
+ if (field.itemType === 'object' || isArrayOfObjects(items)) {
540
543
  return (
541
544
  <ArrayOfObjectsField
542
545
  label={label}
@@ -835,10 +838,18 @@ export function getPlaceholder(field: FieldDefinition): string {
835
838
  switch (field.type) {
836
839
  case 'url':
837
840
  return 'https://...'
841
+ case 'email':
842
+ return 'name@example.com'
838
843
  case 'image':
839
844
  return '/images/...'
845
+ case 'color':
846
+ return '#000000'
840
847
  case 'date':
841
848
  return 'YYYY-MM-DD'
849
+ case 'datetime':
850
+ return 'YYYY-MM-DDTHH:MM'
851
+ case 'time':
852
+ return 'HH:MM'
842
853
  default:
843
854
  return `Enter ${formatFieldLabel(field.name).toLowerCase()}...`
844
855
  }
@@ -2,13 +2,14 @@ import { type Editor, editorViewCtx } from '@milkdown/core'
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
- import { schedulePageReload, STORAGE_KEYS, Z_INDEX } from '../constants'
5
+ import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
6
  import { createMarkdownPage } from '../markdown-api'
7
7
  import {
8
8
  config,
9
9
  currentMarkdownPage,
10
10
  isMarkdownPreview,
11
11
  markdownEditorState,
12
+ pendingCollectionEntries,
12
13
  resetMarkdownEditorState,
13
14
  showToast,
14
15
  startRedirectCountdown,
@@ -89,6 +90,22 @@ export function MarkdownEditorOverlay() {
89
90
  }
90
91
  }, [])
91
92
 
93
+ /** Create any collection entries that were queued during component insertion */
94
+ const flushPendingEntries = useCallback(async () => {
95
+ const entries = pendingCollectionEntries.value
96
+ if (entries.length === 0) return
97
+ pendingCollectionEntries.value = []
98
+ await Promise.all(entries.map(entry =>
99
+ createMarkdownPage(config.value, {
100
+ collection: entry.collection,
101
+ slug: entry.slug,
102
+ title: entry.title,
103
+ frontmatter: entry.frontmatter as any,
104
+ fileExtension: entry.fileExtension,
105
+ })
106
+ ))
107
+ }, [])
108
+
92
109
  const handleSave = useCallback(
93
110
  async (content: string) => {
94
111
  if (isSaving) return
@@ -97,6 +114,7 @@ export function MarkdownEditorOverlay() {
97
114
 
98
115
  setIsSaving(true)
99
116
  try {
117
+ await flushPendingEntries()
100
118
  const result = await updateMarkdownPage(config.value.apiBase, {
101
119
  filePath: currentPage.filePath,
102
120
  content,
@@ -127,8 +145,6 @@ export function MarkdownEditorOverlay() {
127
145
  // Clear pending entry navigation so editor doesn't auto-open after save
128
146
  sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
129
147
  resetMarkdownEditorState()
130
-
131
- schedulePageReload()
132
148
  } else {
133
149
  showToast(result.error || 'Failed to save markdown', 'error')
134
150
  setIsSaving(false)
@@ -162,6 +178,7 @@ export function MarkdownEditorOverlay() {
162
178
 
163
179
  setIsSaving(true)
164
180
  try {
181
+ await flushPendingEntries()
165
182
  const isData = opts.collectionDefinition.type === 'data'
166
183
 
167
184
  // Build frontmatter — for data collections include all fields; for markdown exclude title