@nuasite/cms 0.23.1 → 0.25.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.23.1",
17
+ "version": "0.25.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -24,7 +24,7 @@ import {
24
24
  } from './source-finder'
25
25
  import type { ComponentInstance } from './types'
26
26
  import type { CmsMarkerOptions, CollectionEntry } from './types'
27
- import { firstNonEmptyLine } from './utils'
27
+ import { firstNonEmptyLine, resolveSourcePath } from './utils'
28
28
 
29
29
  // Concurrency limit for parallel processing
30
30
  const MAX_CONCURRENT = 10
@@ -425,9 +425,7 @@ async function processFile(
425
425
 
426
426
  // Update attribute and colorClasses source information if we have an opening tag
427
427
  if (sourceLocation.openingTagSnippet) {
428
- const filePath = path.isAbsolute(sourceLocation.file)
429
- ? sourceLocation.file
430
- : path.join(getProjectRoot(), sourceLocation.file)
428
+ const filePath = resolveSourcePath(sourceLocation.file)
431
429
  try {
432
430
  const content = await fs.readFile(filePath, 'utf-8')
433
431
  const lines = content.split('\n')
@@ -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)
@@ -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)
@@ -575,6 +575,54 @@ export async function findFieldInCollectionEntry(
575
575
  }
576
576
  }
577
577
 
578
+ /**
579
+ * Find multiple fields by name in a specific collection entry's data file.
580
+ * Parses the YAML only once, unlike calling findFieldInCollectionEntry per field.
581
+ */
582
+ export async function findFieldsInCollectionEntry(
583
+ fieldNames: Set<string>,
584
+ collectionName: string,
585
+ collectionSlug: string,
586
+ collectionDefinitions: Record<string, CollectionDefinition>,
587
+ ): Promise<Map<string, SourceLocation>> {
588
+ const def = collectionDefinitions[collectionName]
589
+ if (!def?.entries) return new Map()
590
+
591
+ const entry = def.entries.find((e) => e.slug === collectionSlug)
592
+ if (!entry) return new Map()
593
+
594
+ const info: CollectionInfo = { name: collectionName, slug: collectionSlug, file: entry.sourcePath }
595
+
596
+ try {
597
+ const filePath = path.join(getProjectRoot(), entry.sourcePath)
598
+ const cached = await getCachedMarkdownFile(filePath)
599
+ if (!cached) return new Map()
600
+
601
+ if (def.type === 'data') {
602
+ return findFieldsByNameInYaml(cached.content, 0, fieldNames, cached.lines, info)
603
+ }
604
+
605
+ // For markdown, search inside frontmatter only
606
+ const { lines } = cached
607
+ let fmStart = -1
608
+ let fmEnd = -1
609
+ for (let i = 0; i < lines.length; i++) {
610
+ if (lines[i]?.trim() === '---') {
611
+ if (fmStart === -1) fmStart = i
612
+ else {
613
+ fmEnd = i
614
+ break
615
+ }
616
+ }
617
+ }
618
+ if (fmEnd <= 0) return new Map()
619
+ const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
620
+ return findFieldsByNameInYaml(yamlStr, fmStart + 1, fieldNames, lines, info)
621
+ } catch {
622
+ return new Map()
623
+ }
624
+ }
625
+
578
626
  /**
579
627
  * Walk a YAML AST to find a field by key name (regardless of its value).
580
628
  */
@@ -585,13 +633,30 @@ function findFieldByNameInYaml(
585
633
  fileLines: string[],
586
634
  collectionInfo: CollectionInfo,
587
635
  ): SourceLocation | undefined {
636
+ const results = findFieldsByNameInYaml(yamlStr, lineOffset, new Set([fieldName]), fileLines, collectionInfo)
637
+ return results.get(fieldName)
638
+ }
639
+
640
+ /**
641
+ * Walk a YAML AST to find multiple fields by key name in a single parse.
642
+ * Returns a map of fieldName → SourceLocation for all matched fields.
643
+ */
644
+ function findFieldsByNameInYaml(
645
+ yamlStr: string,
646
+ lineOffset: number,
647
+ fieldNames: Set<string>,
648
+ fileLines: string[],
649
+ collectionInfo: CollectionInfo,
650
+ ): Map<string, SourceLocation> {
588
651
  const lineCounter = new LineCounter()
589
652
  const doc = parseDocument(yamlStr, { lineCounter })
590
- if (!isMap(doc.contents)) return undefined
653
+ const results = new Map<string, SourceLocation>()
654
+ if (!isMap(doc.contents)) return results
591
655
 
592
656
  for (const pair of doc.contents.items) {
593
657
  if (!isPair(pair) || !isScalar(pair.key)) continue
594
- if (String(pair.key.value) !== fieldName) continue
658
+ const key = String(pair.key.value)
659
+ if (!fieldNames.has(key)) continue
595
660
  if (!isScalar(pair.value)) continue
596
661
 
597
662
  const keyRange = (pair.key as any).range as [number, number, number] | undefined
@@ -600,17 +665,20 @@ function findFieldByNameInYaml(
600
665
  const endLine = (valRange ? lineCounter.linePos(valRange[1]).line : startLine - lineOffset) + lineOffset
601
666
 
602
667
  const snippet = fileLines.slice(startLine - 1, endLine).join('\n')
603
- return {
668
+ results.set(key, {
604
669
  file: collectionInfo.file,
605
670
  line: startLine,
606
671
  snippet,
607
672
  type: 'collection',
608
- variableName: fieldName,
673
+ variableName: key,
609
674
  collectionName: collectionInfo.name,
610
675
  collectionSlug: collectionInfo.slug,
611
- }
676
+ })
677
+
678
+ // Early exit if all fields found
679
+ if (results.size === fieldNames.size) break
612
680
  }
613
- return undefined
681
+ return results
614
682
  }
615
683
 
616
684
  // ============================================================================
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  import { getProjectRoot } from '../config'
5
- import { escapeRegex } from '../utils'
5
+ import { escapeRegex, resolveSourcePath } from '../utils'
6
6
  import { buildDefinitionPath, parseExpressionPath } from './ast-extractors'
7
7
  import { getCachedParsedFile } from './ast-parser'
8
8
  import { findComponentProp, findExpressionProp, findSpreadProp } from './element-finder'
@@ -384,9 +384,7 @@ export async function findAttributeSourceLocation(
384
384
  // Get the property name (last part of the expression)
385
385
  const propName = exprPath.includes('.') ? exprPath.split('.').pop()! : exprPath
386
386
 
387
- const filePath = path.isAbsolute(sourceFilePath)
388
- ? sourceFilePath
389
- : path.join(getProjectRoot(), sourceFilePath)
387
+ const filePath = resolveSourcePath(sourceFilePath)
390
388
 
391
389
  const cached = await getCachedParsedFile(filePath)
392
390
  if (!cached) return undefined
@@ -4,10 +4,16 @@ import { parse as parseYaml } from 'yaml'
4
4
 
5
5
  import { getProjectRoot } from '../config'
6
6
  import type { Attribute, CollectionDefinition, ManifestEntry } from '../types'
7
- import { escapeRegex, generateSourceHash } from '../utils'
7
+ import { escapeRegex, generateSourceHash, resolveSourcePath } from '../utils'
8
8
  import { buildDefinitionPath } from './ast-extractors'
9
9
  import { getCachedParsedFile } from './ast-parser'
10
- import { buildCollectionTextIndex, findFieldInCollectionEntry, findTextInAnyCollectionFrontmatter, lookupCollectionText } from './collection-finder'
10
+ import {
11
+ buildCollectionTextIndex,
12
+ findFieldInCollectionEntry,
13
+ findFieldsInCollectionEntry,
14
+ findTextInAnyCollectionFrontmatter,
15
+ lookupCollectionText,
16
+ } from './collection-finder'
11
17
  import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
12
18
  import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
13
19
  import { initializeSearchIndex } from './search-index'
@@ -444,9 +450,7 @@ export async function extractSourceSnippet(
444
450
  tag: string,
445
451
  ): Promise<string | undefined> {
446
452
  try {
447
- const filePath = path.isAbsolute(sourceFile)
448
- ? sourceFile
449
- : path.join(getProjectRoot(), sourceFile)
453
+ const filePath = resolveSourcePath(sourceFile)
450
454
 
451
455
  const content = await fs.readFile(filePath, 'utf-8')
452
456
  const lines = content.split('\n')
@@ -583,9 +587,7 @@ export async function enhanceManifestWithSourceSnippets(
583
587
 
584
588
  // Also update attribute and colorClasses source info from the opening tag
585
589
  try {
586
- const filePath = path.isAbsolute(imageLocation.file)
587
- ? imageLocation.file
588
- : path.join(getProjectRoot(), imageLocation.file)
590
+ const filePath = resolveSourcePath(imageLocation.file)
589
591
  const { lines } = await readFileWithCache(filePath)
590
592
  const openingTagInfo = extractOpeningTagWithLine(lines, imageLocation.line - 1, entry.tag)
591
593
 
@@ -620,9 +622,7 @@ export async function enhanceManifestWithSourceSnippets(
620
622
  // Fallback for expression-based src attributes (src={variable})
621
623
  if (entry.sourcePath && entry.sourceLine) {
622
624
  try {
623
- const filePath = path.isAbsolute(entry.sourcePath)
624
- ? entry.sourcePath
625
- : path.join(getProjectRoot(), entry.sourcePath)
625
+ const filePath = resolveSourcePath(entry.sourcePath)
626
626
  const cached = await getCachedParsedFile(filePath)
627
627
  if (cached) {
628
628
  const nearbyImg = findImageElementNearLine(cached.ast, entry.sourceLine, cached.lines)
@@ -662,6 +662,18 @@ export async function enhanceManifestWithSourceSnippets(
662
662
  return [id, entry] as const
663
663
  }
664
664
 
665
+ // Collection text: resolve directly from the data file
666
+ if (entry.text?.trim() && entry.collectionName && entry.collectionSlug && collectionDefinitions) {
667
+ const textLocation = await resolveCollectionTextField(
668
+ entry,
669
+ collectionDefinitions,
670
+ referenceIndex,
671
+ )
672
+ if (textLocation) {
673
+ return [id, textLocation] as const
674
+ }
675
+ }
676
+
665
677
  // Skip if already has sourceSnippet or missing source info
666
678
  if (entry.sourceSnippet || !entry.sourcePath || !entry.sourceLine || !entry.tag) {
667
679
  return [id, entry] as const
@@ -669,9 +681,7 @@ export async function enhanceManifestWithSourceSnippets(
669
681
 
670
682
  // Read file once and extract both snippets
671
683
  try {
672
- const filePath = path.isAbsolute(entry.sourcePath)
673
- ? entry.sourcePath
674
- : path.join(getProjectRoot(), entry.sourcePath)
684
+ const filePath = resolveSourcePath(entry.sourcePath)
675
685
 
676
686
  const { content, lines } = await readFileWithCache(filePath)
677
687
 
@@ -973,36 +983,33 @@ async function resolveCollectionImageField(
973
983
  return undefined
974
984
  }
975
985
 
976
- // Multiple image fields — try to match the rendered URL to a field value.
986
+ // Multiple image fields — fetch all in one YAML parse, then match by value
977
987
  const imgSrc = entry.imageMetadata!.src
988
+ const allResults = await findFieldsInCollectionEntry(
989
+ new Set(imageFields.map(f => f.name)),
990
+ entry.collectionName!,
991
+ entry.collectionSlug!,
992
+ collectionDefinitions,
993
+ )
994
+
978
995
  let firstFieldResult: SourceLocation | undefined
979
996
  for (const field of imageFields) {
980
- const fieldResult = await findFieldInCollectionEntry(
981
- field.name,
982
- entry.collectionName!,
983
- entry.collectionSlug!,
984
- collectionDefinitions,
985
- )
997
+ const fieldResult = allResults.get(field.name)
986
998
  if (!fieldResult?.snippet) continue
987
999
 
988
- // Remember the first resolved field as fallback
989
1000
  firstFieldResult ??= fieldResult
990
1001
 
991
- // Check if the field's value matches the rendered URL (exact or after Astro processing)
992
- const yamlKeyMatch = fieldResult.snippet.match(/^\s*[\w][\w-]*:\s*/)
993
- if (yamlKeyMatch) {
994
- try {
995
- const parsed = parseYaml(fieldResult.snippet)
996
- if (parsed && typeof parsed === 'object') {
997
- const key = fieldResult.snippet.match(/^\s*([\w][\w-]*):/)?.[1]
998
- const value = key ? (parsed as Record<string, unknown>)[key] : undefined
999
- if (typeof value === 'string' && (value === imgSrc || imgSrc.includes(value) || value.includes(imgSrc))) {
1000
- return applyCollectionSource(entry, fieldResult, referenceIndex)
1001
- }
1002
+ try {
1003
+ const cleaned = fieldResult.snippet.replace(/,\s*$/, '')
1004
+ const parsed = parseYaml(cleaned)
1005
+ if (parsed && typeof parsed === 'object') {
1006
+ const value = (parsed as Record<string, unknown>)[field.name]
1007
+ if (typeof value === 'string' && (value === imgSrc || imgSrc.includes(value) || value.includes(imgSrc))) {
1008
+ return applyCollectionSource(entry, fieldResult, referenceIndex)
1002
1009
  }
1003
- } catch {
1004
- // Not valid YAML
1005
1010
  }
1011
+ } catch {
1012
+ // Not valid YAML/JSON
1006
1013
  }
1007
1014
  }
1008
1015
 
@@ -1014,6 +1021,110 @@ async function resolveCollectionImageField(
1014
1021
  return undefined
1015
1022
  }
1016
1023
 
1024
+ // ============================================================================
1025
+ // Collection Text Resolution
1026
+ // ============================================================================
1027
+
1028
+ /**
1029
+ * Resolve a collection text entry directly from the data file.
1030
+ * Two strategies, tried in order:
1031
+ *
1032
+ * 1. **Source-map** — read the template expression (e.g., {post.data.title}),
1033
+ * extract the field name, look it up by name in the data file.
1034
+ * 2. **Value match** — iterate over collection fields and compare rendered
1035
+ * text against field values. Handles static/hardcoded text that exists
1036
+ * in both the template and a collection data file.
1037
+ */
1038
+ async function resolveCollectionTextField(
1039
+ entry: ManifestEntry,
1040
+ collectionDefinitions: Record<string, CollectionDefinition>,
1041
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
1042
+ ): Promise<ManifestEntry | undefined> {
1043
+ const colDef = collectionDefinitions[entry.collectionName!]
1044
+ if (!colDef) return undefined
1045
+
1046
+ // Try template expression as source map (e.g., {post.data.title} → "title")
1047
+ const fieldNames = await extractDataFieldNames(entry)
1048
+ if (fieldNames.size === 1) {
1049
+ const fieldResult = await findFieldInCollectionEntry(
1050
+ fieldNames.values().next().value!,
1051
+ entry.collectionName!,
1052
+ entry.collectionSlug!,
1053
+ collectionDefinitions,
1054
+ )
1055
+ if (fieldResult) {
1056
+ return applyCollectionSource(entry, fieldResult, referenceIndex, { allowStyling: false })
1057
+ }
1058
+ } else if (fieldNames.size > 1) {
1059
+ const result = await matchFieldByValue(entry, fieldNames, collectionDefinitions, referenceIndex)
1060
+ if (result) return result
1061
+ }
1062
+
1063
+ // Fallback: match rendered text against all non-image field values
1064
+ const allFieldNames = new Set(colDef.fields.filter(f => f.type !== 'image').map(f => f.name))
1065
+ if (allFieldNames.size > 0) {
1066
+ return matchFieldByValue(entry, allFieldNames, collectionDefinitions, referenceIndex)
1067
+ }
1068
+
1069
+ return undefined
1070
+ }
1071
+
1072
+ /**
1073
+ * Extract .data.fieldName references from the template expression at the entry's source location.
1074
+ * Returns an empty set if the entry lacks source info or the template has no data field expressions.
1075
+ */
1076
+ async function extractDataFieldNames(entry: ManifestEntry): Promise<Set<string>> {
1077
+ const fieldNames = new Set<string>()
1078
+ if (!entry.sourcePath || !entry.sourceLine || !entry.tag) return fieldNames
1079
+
1080
+ const cached = await getCachedParsedFile(resolveSourcePath(entry.sourcePath))
1081
+ if (!cached) return fieldNames
1082
+
1083
+ const snippet = extractCompleteTagSnippet(cached.lines, entry.sourceLine - 1, entry.tag)
1084
+ if (!snippet) return fieldNames
1085
+
1086
+ let match: RegExpExecArray | null
1087
+ const pattern = /\.data\.(\w+)/g
1088
+ while ((match = pattern.exec(snippet)) !== null) {
1089
+ fieldNames.add(match[1]!)
1090
+ }
1091
+ return fieldNames
1092
+ }
1093
+
1094
+ /** Match entry text against collection field values to find the source field. */
1095
+ async function matchFieldByValue(
1096
+ entry: ManifestEntry,
1097
+ fieldNames: Set<string>,
1098
+ collectionDefinitions: Record<string, CollectionDefinition>,
1099
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
1100
+ ): Promise<ManifestEntry | undefined> {
1101
+ const normalizedText = normalizeText(entry.text!)
1102
+ const fieldResults = await findFieldsInCollectionEntry(
1103
+ fieldNames,
1104
+ entry.collectionName!,
1105
+ entry.collectionSlug!,
1106
+ collectionDefinitions,
1107
+ )
1108
+
1109
+ for (const [fieldName, fieldResult] of fieldResults) {
1110
+ if (!fieldResult.snippet) continue
1111
+
1112
+ try {
1113
+ const cleaned = fieldResult.snippet.replace(/,\s*$/, '')
1114
+ const parsed = parseYaml(cleaned)
1115
+ if (parsed && typeof parsed === 'object') {
1116
+ const value = (parsed as Record<string, unknown>)[fieldName]
1117
+ if (typeof value === 'string' && normalizeText(value) === normalizedText) {
1118
+ return applyCollectionSource(entry, fieldResult, referenceIndex, { allowStyling: false })
1119
+ }
1120
+ }
1121
+ } catch {
1122
+ // Not valid YAML/JSON
1123
+ }
1124
+ }
1125
+ return undefined
1126
+ }
1127
+
1017
1128
  // ============================================================================
1018
1129
  // Image Expression Resolution
1019
1130
  // ============================================================================
package/src/utils.ts CHANGED
@@ -133,6 +133,19 @@ export function escapeReplacement(str: string): string {
133
133
  return str.replace(/\$/g, '$$$$')
134
134
  }
135
135
 
136
+ // ============================================================================
137
+ // Path Resolution
138
+ // ============================================================================
139
+
140
+ /**
141
+ * Resolve a source path to an absolute filesystem path.
142
+ * If the path is already absolute it is returned as-is; otherwise it is
143
+ * joined with the project root directory.
144
+ */
145
+ export function resolveSourcePath(sourcePath: string): string {
146
+ return path.isAbsolute(sourcePath) ? sourcePath : path.join(getProjectRoot(), sourcePath)
147
+ }
148
+
136
149
  // ============================================================================
137
150
  // Path Validation
138
151
  // ============================================================================