@nuasite/cms 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.js CHANGED
@@ -381,7 +381,7 @@ function CS(t, e) {
381
381
  function ES(t, e) {
382
382
  return typeof e == "function" ? e(t) : e;
383
383
  }
384
- const J_ = "0.24.0", j_ = J_, ct = {
384
+ const J_ = "0.26.0", j_ = J_, ct = {
385
385
  /** Highlight overlay for hovered elements */
386
386
  HIGHLIGHT: 2147483644,
387
387
  /** Hover outline for elements/components */
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.24.0",
17
+ "version": "0.26.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -10,7 +10,6 @@ import { extractComponentName, processHtml } from './html-processor'
10
10
  import type { ManifestWriter } from './manifest-writer'
11
11
  import { generateComponentPreviews } from './preview-generator'
12
12
  import {
13
- clearCollectionTextIndex,
14
13
  clearSourceFinderCache,
15
14
  extractOpeningTagWithLine,
16
15
  findCollectionSource,
@@ -777,7 +776,6 @@ export async function processBuildOutput(
777
776
 
778
777
  // Clear caches from previous builds and initialize search index
779
778
  clearSourceFinderCache()
780
- clearCollectionTextIndex()
781
779
 
782
780
  const htmlFiles = await findHtmlFiles(outDir)
783
781
 
@@ -454,12 +454,12 @@ function parseContentConfigReferences(
454
454
  return result
455
455
  }
456
456
 
457
- /** Valid field type names exported by `field` helper from @nuasite/cms */
457
+ /** Valid field type names exported by `n` helper from @nuasite/cms */
458
458
  const FIELD_HELPER_TYPES = new Set(['image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
459
459
 
460
460
  /**
461
461
  * Parse the content config file to extract explicit field type hints:
462
- * - `field.image(...)`, `field.url(...)`, etc. from @nuasite/cms
462
+ * - `n.image()`, `n.url()`, etc. from @nuasite/cms
463
463
  * - `z.enum([...])` for select options
464
464
  *
465
465
  * Returns a map: collectionName → fieldName → { type, options? }
@@ -472,8 +472,8 @@ function parseContentConfigFieldTypes(
472
472
  for (const { collectionName, schemaBody } of schemaBlocks) {
473
473
  const fields = new Map<string, { type: FieldType; options?: string[] }>()
474
474
 
475
- // Detect field.image(...), field.url(...), etc.
476
- const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*field\.(\w+)\s*\(/g)
475
+ // Detect n.image(), n.url(), etc.
476
+ const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*n\.(\w+)/g)
477
477
  for (const m of fieldHelpers) {
478
478
  const fieldName = m[1]!
479
479
  const helperName = m[2]!
@@ -500,6 +500,36 @@ function parseContentConfigFieldTypes(
500
500
  return result
501
501
  }
502
502
 
503
+ /**
504
+ * Extract all top-level field names from a schema body string.
505
+ * Matches `fieldName:` patterns at the start of lines within z.object({...}).
506
+ */
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]!)
511
+ }
512
+ return names
513
+ }
514
+
515
+ /**
516
+ * When a content config schema exists, filter scanned fields to only include
517
+ * those defined in the schema. This prevents stale or extra frontmatter fields
518
+ * from appearing in the CMS editor.
519
+ */
520
+ function filterFieldsBySchema(
521
+ collections: Record<string, CollectionDefinition>,
522
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
523
+ ): void {
524
+ for (const { collectionName, schemaBody } of schemaBlocks) {
525
+ const def = collections[collectionName]
526
+ if (!def) continue
527
+ const schemaNames = extractSchemaFieldNames(schemaBody)
528
+ if (schemaNames.size === 0) continue
529
+ def.fields = def.fields.filter(f => schemaNames.has(f.name))
530
+ }
531
+ }
532
+
503
533
  /**
504
534
  * Apply field type overrides from config parsing to scanned collections.
505
535
  */
@@ -759,6 +789,7 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
759
789
 
760
790
  // Post-scan: apply explicit type hints, detect references, and derived fields
761
791
  const schemaBlocks = await parseContentConfigSchemaBlocks()
792
+ filterFieldsBySchema(collections, schemaBlocks)
762
793
  applyConfigFieldTypes(collections, schemaBlocks)
763
794
  await detectReferenceFields(collections, schemaBlocks)
764
795
  detectDerivedHrefFields(collections)
@@ -17,10 +17,10 @@ import { processHtml } from './html-processor'
17
17
  import type { ManifestWriter } from './manifest-writer'
18
18
  import type { MediaStorageAdapter } from './media/types'
19
19
  import {
20
+ enhanceManifestWithSourceSnippets,
20
21
  findCollectionSource,
21
22
  findImageSourceLocation,
22
23
  findSourceLocation,
23
- initializeSearchIndex,
24
24
  parseMarkdownContent,
25
25
  reindexDirtyFiles,
26
26
  } from './source-finder'
@@ -424,7 +424,7 @@ async function markHtmlForDev(
424
424
  * Phase 2 (background): Resolve source locations, enhance snippets, populate
425
425
  * component props, and update the manifest. Runs after the HTML response is sent.
426
426
  */
427
- async function enhanceManifestInBackground(
427
+ export async function enhanceManifestInBackground(
428
428
  pagePath: string,
429
429
  entries: Record<string, ManifestEntry>,
430
430
  components: Record<string, ComponentInstance>,
@@ -541,31 +541,31 @@ async function enhanceManifestInBackground(
541
541
  }
542
542
  }
543
543
 
544
- // Ensure the search index is initialized
545
- await initializeSearchIndex()
544
+ const enhanced = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
546
545
 
547
- // Re-resolve sources with the search index
548
- for (const entry of Object.values(entries)) {
546
+ // Fallback for entries without sourcePath — search index can still find them
547
+ for (const entry of Object.values(enhanced)) {
548
+ if (entry.sourceSnippet || entry.sourcePath) continue
549
549
  if (entry.imageMetadata?.src) {
550
- const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
551
- if (imageSource) {
552
- entry.sourcePath = imageSource.file
553
- entry.sourceLine = imageSource.line
554
- entry.sourceSnippet = imageSource.snippet
550
+ const loc = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
551
+ if (loc) {
552
+ entry.sourcePath = loc.file
553
+ entry.sourceLine = loc.line
554
+ entry.sourceSnippet = loc.snippet
555
555
  }
556
556
  } else if (entry.text && entry.tag) {
557
- const textSource = await findSourceLocation(entry.text, entry.tag)
558
- if (textSource) {
559
- entry.sourcePath = textSource.file
560
- entry.sourceLine = textSource.line
561
- entry.sourceSnippet = textSource.snippet
562
- if (textSource.variableName) entry.variableName = textSource.variableName
557
+ const loc = await findSourceLocation(entry.text, entry.tag)
558
+ if (loc) {
559
+ entry.sourcePath = loc.file
560
+ entry.sourceLine = loc.line
561
+ entry.sourceSnippet = loc.snippet
562
+ if (loc.variableName) entry.variableName = loc.variableName
563
563
  }
564
564
  }
565
565
  }
566
566
 
567
567
  // Update the manifest with fully-resolved entries and component props
568
- manifestWriter.addPage(pagePath, entries, components, collection, seo)
568
+ manifestWriter.addPage(pagePath, enhanced, components, collection, seo)
569
569
  } catch (error) {
570
570
  console.error('[cms] Background enhancement failed:', error)
571
571
  }
@@ -1,42 +1,47 @@
1
+ import { z } from 'astro/zod'
2
+
1
3
  /**
2
- * Semantic field type wrappers for Zod schemas in content collections.
4
+ * Semantic field type schemas for content collections.
3
5
  *
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.
6
+ * Each method returns a `z.string()` schema that Astro resolves
7
+ * statically (concrete return type, no generics). The CMS collection
8
+ * scanner detects them by name in the source and renders the
9
+ * appropriate editor input.
10
+ *
11
+ * Chain Zod methods as usual (`.optional()`, `.default()`, etc.).
7
12
  *
8
13
  * @example
9
14
  * ```ts
10
- * import { field } from '@nuasite/cms'
15
+ * import { n } from '@nuasite/cms'
11
16
  * import { z } from 'astro/zod'
12
17
  *
13
18
  * 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()),
19
+ * photo: n.image(),
20
+ * website: n.url().optional(),
21
+ * contact: n.email(),
22
+ * accent: n.color(),
23
+ * publishedAt: n.date(),
24
+ * startsAt: n.datetime(),
25
+ * opensAt: n.time(),
26
+ * bio: n.textarea(),
22
27
  * })
23
28
  * ```
24
29
  */
25
- export const field = {
30
+ export const n = {
26
31
  /** Image picker (opens media library) */
27
- image: <T>(schema: T): T => schema,
32
+ image: () => z.string().describe('cms:image'),
28
33
  /** URL input */
29
- url: <T>(schema: T): T => schema,
34
+ url: () => z.string().describe('cms:url'),
30
35
  /** Email input */
31
- email: <T>(schema: T): T => schema,
36
+ email: () => z.string().describe('cms:email'),
32
37
  /** Color picker */
33
- color: <T>(schema: T): T => schema,
38
+ color: () => z.string().describe('cms:color'),
34
39
  /** Date picker */
35
- date: <T>(schema: T): T => schema,
40
+ date: () => z.string().describe('cms:date'),
36
41
  /** Date + time picker */
37
- datetime: <T>(schema: T): T => schema,
42
+ datetime: () => z.string().describe('cms:datetime'),
38
43
  /** Time picker */
39
- time: <T>(schema: T): T => schema,
44
+ time: () => z.string().describe('cms:time'),
40
45
  /** Multiline textarea */
41
- textarea: <T>(schema: T): T => schema,
46
+ textarea: () => z.string().describe('cms:textarea'),
42
47
  }
@@ -162,7 +162,6 @@ export async function handleCreateMarkdown(
162
162
  const fullFrontmatter: BlogFrontmatter = {
163
163
  title,
164
164
  date: new Date().toISOString().split('T')[0]!,
165
- draft: true,
166
165
  ...frontmatter,
167
166
  }
168
167
  fileContent = serializeFrontmatter(fullFrontmatter, content)
package/src/index.ts CHANGED
@@ -346,7 +346,7 @@ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void })
346
346
  logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
347
347
  }
348
348
 
349
- export { field } from './field-types'
349
+ export { n } from './field-types'
350
350
  export { createContemberStorageAdapter as contemberMedia } from './media/contember'
351
351
  export { createLocalStorageAdapter as localMedia } from './media/local'
352
352
  export { createS3StorageAdapter as s3Media } from './media/s3'
@@ -1,4 +1,4 @@
1
- import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry } from './types'
1
+ import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry, SourceLocation } from './types'
2
2
 
3
3
  // ============================================================================
4
4
  // File Parsing Cache - Avoid re-parsing the same files
@@ -18,6 +18,9 @@ const textSearchIndex: SearchIndexEntry[] = []
18
18
  const imageSearchIndex: ImageIndexEntry[] = []
19
19
  let searchIndexInitialized = false
20
20
 
21
+ /** Pre-built reverse index: normalizedText → SourceLocation[] (collection data files) */
22
+ let collectionTextIndex: Map<string, SourceLocation[]> | null = null
23
+
21
24
  /** Files that changed since last indexing — tracked by Vite watcher */
22
25
  const dirtyFiles = new Set<string>()
23
26
 
@@ -61,6 +64,14 @@ export function addToImageSearchIndex(entry: ImageIndexEntry): void {
61
64
  imageSearchIndex.push(entry)
62
65
  }
63
66
 
67
+ export function getCollectionTextIndex(): Map<string, SourceLocation[]> | null {
68
+ return collectionTextIndex
69
+ }
70
+
71
+ export function setCollectionTextIndex(index: Map<string, SourceLocation[]> | null): void {
72
+ collectionTextIndex = index
73
+ }
74
+
64
75
  // ============================================================================
65
76
  // Dirty File Tracking (incremental re-indexing)
66
77
  // ============================================================================
@@ -120,4 +131,5 @@ export function clearSourceFinderCache(): void {
120
131
  textSearchIndex.length = 0
121
132
  imageSearchIndex.length = 0
122
133
  searchIndexInitialized = false
134
+ collectionTextIndex = null
123
135
  }
@@ -4,7 +4,7 @@ import { isMap, isPair, isScalar, isSeq, LineCounter, parseDocument } from 'yaml
4
4
 
5
5
  import { getProjectRoot } from '../config'
6
6
  import type { CollectionDefinition } from '../types'
7
- import { getMarkdownFileCache } from './cache'
7
+ import { getCollectionTextIndex, getMarkdownFileCache, setCollectionTextIndex } from './cache'
8
8
  import { normalizeText } from './snippet-utils'
9
9
  import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
10
10
 
@@ -12,8 +12,6 @@ import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
12
12
  // Collection Text Index — pre-built reverse index for fast text→source lookups
13
13
  // ============================================================================
14
14
 
15
- /** Pre-built index: normalizedText → SourceLocation (with collection metadata) */
16
- let collectionTextIndex: Map<string, SourceLocation[]> | null = null
17
15
  let collectionTextIndexPromise: Promise<void> | null = null
18
16
 
19
17
  /**
@@ -23,7 +21,7 @@ let collectionTextIndexPromise: Promise<void> | null = null
23
21
  export async function buildCollectionTextIndex(
24
22
  collections: Record<string, CollectionDefinition>,
25
23
  ): Promise<void> {
26
- if (collectionTextIndex) return
24
+ if (getCollectionTextIndex()) return
27
25
  if (collectionTextIndexPromise) return collectionTextIndexPromise
28
26
  collectionTextIndexPromise = doBuildCollectionTextIndex(collections)
29
27
  try {
@@ -76,7 +74,7 @@ async function doBuildCollectionTextIndex(
76
74
  }))
77
75
  }
78
76
 
79
- collectionTextIndex = index
77
+ setCollectionTextIndex(index)
80
78
  }
81
79
 
82
80
  /**
@@ -168,9 +166,10 @@ export function lookupCollectionText(
168
166
  textContent: string,
169
167
  referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
170
168
  ): SourceLocation | undefined {
171
- if (!collectionTextIndex) return undefined
169
+ const index = getCollectionTextIndex()
170
+ if (!index) return undefined
172
171
  const normalized = normalizeText(textContent)
173
- const locations = collectionTextIndex.get(normalized)
172
+ const locations = index.get(normalized)
174
173
  if (!locations || locations.length === 0) return undefined
175
174
 
176
175
  // Prefer locations from referenced collections
@@ -184,11 +183,6 @@ export function lookupCollectionText(
184
183
  return locations[0]
185
184
  }
186
185
 
187
- /** Clear the collection text index (called when collection files change) */
188
- export function clearCollectionTextIndex(): void {
189
- collectionTextIndex = null
190
- }
191
-
192
186
  // ============================================================================
193
187
  // Markdown File Cache
194
188
  // ============================================================================
@@ -25,7 +25,6 @@ export { findImageSourceLocation } from './image-finder'
25
25
  // Collection/markdown finding
26
26
  export {
27
27
  buildCollectionTextIndex,
28
- clearCollectionTextIndex,
29
28
  findCollectionSource,
30
29
  findFieldInCollectionEntry,
31
30
  findMarkdownSourceLocation,
@@ -17,9 +17,9 @@ import {
17
17
  getTextSearchIndex,
18
18
  isSearchIndexInitialized,
19
19
  removeFileFromIndexes,
20
+ setCollectionTextIndex,
20
21
  setSearchIndexInitialized,
21
22
  } from './cache'
22
- import { clearCollectionTextIndex } from './collection-finder'
23
23
  import { extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
24
24
  import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
25
25
 
@@ -167,7 +167,7 @@ async function doReindexDirtyFiles(): Promise<void> {
167
167
  // Also clear the markdown file cache and collection text index
168
168
  // so collection content is re-read and re-indexed from disk
169
169
  getMarkdownFileCache().clear()
170
- clearCollectionTextIndex()
170
+ setCollectionTextIndex(null)
171
171
 
172
172
  for (const absPath of filesToReindex) {
173
173
  const relFile = path.relative(projectRoot, absPath)