@nuasite/cms 0.40.0 → 0.42.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.40.0",
17
+ "version": "0.42.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -1,8 +1,9 @@
1
+ import type { Dirent } from 'node:fs'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
4
5
  import { getProjectRoot } from './config'
5
- import { parseContentConfig, type ParsedConfig } from './content-config-ast'
6
+ import { parseContentConfig, type ParsedConfig, type ParsedField } from './content-config-ast'
6
7
  import { slugifyHref } from './shared'
7
8
  import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
8
9
 
@@ -213,9 +214,14 @@ function inferFieldType(value: unknown, key: string): FieldType {
213
214
  }
214
215
 
215
216
  /**
216
- * Merge field observations from multiple files to determine final field definition
217
+ * Merge field observations from multiple files to determine final field definition.
218
+ * `depth` guards against pathological deeply-nested content blowing the stack —
219
+ * real-world YAML/JSON rarely exceeds 5 levels, so the cap is well above realistic use.
217
220
  */
218
- function mergeFieldObservations(observations: FieldObservation[]): FieldDefinition[] {
221
+ const MAX_NESTED_FIELD_DEPTH = 16
222
+
223
+ function mergeFieldObservations(observations: FieldObservation[], depth: number = 0): FieldDefinition[] {
224
+ if (depth >= MAX_NESTED_FIELD_DEPTH) return []
219
225
  const fields: FieldDefinition[] = []
220
226
 
221
227
  for (const obs of observations) {
@@ -283,12 +289,26 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
283
289
  for (const item of objectItems) {
284
290
  collectFieldObservations(subFieldMap, item, objectItems.length)
285
291
  }
286
- field.fields = mergeFieldObservations(Array.from(subFieldMap.values()))
292
+ field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
287
293
  }
288
294
  }
289
295
  }
290
296
  }
291
297
 
298
+ // For plain object values, recurse into sub-fields so the editor can render them.
299
+ if (fieldType === 'object') {
300
+ const objectValues = nonNullValues.filter(
301
+ (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v),
302
+ )
303
+ if (objectValues.length > 0) {
304
+ const subFieldMap = new Map<string, FieldObservation>()
305
+ for (const item of objectValues) {
306
+ collectFieldObservations(subFieldMap, item, objectValues.length)
307
+ }
308
+ field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
309
+ }
310
+ }
311
+
292
312
  fields.push(field)
293
313
  }
294
314
 
@@ -311,7 +331,7 @@ function collectFieldObservations(
311
331
  }
312
332
  }
313
333
 
314
- function buildCollectionDefinition(
334
+ function assembleCollectionDefinition(
315
335
  collectionName: string,
316
336
  contentDir: string,
317
337
  fieldMap: Map<string, FieldObservation>,
@@ -340,6 +360,84 @@ function buildCollectionDefinition(
340
360
  }
341
361
  }
342
362
 
363
+ function getCollectionSourceBasePath(basePath: string, collectionName: string, contentDir: string): string {
364
+ const projectRoot = getProjectRoot()
365
+ const defaultContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir)
366
+ const defaultCollectionPath = path.join(defaultContentDir, collectionName)
367
+ if (path.resolve(basePath) === path.resolve(defaultCollectionPath)) {
368
+ return path.join(contentDir, collectionName)
369
+ }
370
+
371
+ const relativeBase = path.relative(projectRoot, basePath)
372
+ if (relativeBase && !relativeBase.startsWith('..') && !path.isAbsolute(relativeBase)) {
373
+ return relativeBase
374
+ }
375
+ return basePath
376
+ }
377
+
378
+ async function buildCollectionDefinition(
379
+ basePath: string,
380
+ sources: Array<{ slug: string; relPath: string }>,
381
+ collectionName: string,
382
+ contentDir: string,
383
+ ): Promise<CollectionDefinition | null> {
384
+ if (sources.length === 0) return null
385
+
386
+ const sourceBasePath = getCollectionSourceBasePath(basePath, collectionName, contentDir)
387
+ const hasMd = sources.some(s => s.relPath.endsWith('.md'))
388
+ const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
389
+
390
+ const fieldMap = new Map<string, FieldObservation>()
391
+ const allDirectives: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
392
+ const entryInfos: CollectionEntryInfo[] = []
393
+ let hasDraft = false
394
+
395
+ const fileContents = await Promise.all(
396
+ sources.map(s => fs.readFile(path.join(basePath, s.relPath), 'utf-8')),
397
+ )
398
+
399
+ for (let i = 0; i < sources.length; i++) {
400
+ const source = sources[i]!
401
+ const content = fileContents[i]!
402
+ const frontmatter = parseFrontmatter(content)
403
+
404
+ const directives = parseFieldDirectives(content)
405
+ for (const [key, value] of Object.entries(directives)) {
406
+ if (!allDirectives[key]) {
407
+ allDirectives[key] = value
408
+ }
409
+ }
410
+
411
+ const entryInfo: CollectionEntryInfo = {
412
+ slug: source.slug,
413
+ sourcePath: path.join(sourceBasePath, source.relPath),
414
+ }
415
+ if (frontmatter) {
416
+ if (typeof frontmatter.title === 'string') {
417
+ entryInfo.title = frontmatter.title
418
+ }
419
+ if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
420
+ entryInfo.draft = true
421
+ }
422
+ entryInfo.data = frontmatter
423
+ }
424
+ entryInfos.push(entryInfo)
425
+
426
+ if (!frontmatter) continue
427
+
428
+ if (frontmatter.draft === true) hasDraft = true
429
+ collectFieldObservations(fieldMap, frontmatter, sources.length)
430
+ }
431
+
432
+ const def = assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
433
+ path: sourceBasePath,
434
+ supportsDraft: hasDraft,
435
+ fileExtension,
436
+ })
437
+ assignFieldMetadata(def.fields, allDirectives)
438
+ return def
439
+ }
440
+
343
441
  /**
344
442
  * Scan a single collection directory and infer its schema
345
443
  */
@@ -378,58 +476,86 @@ async function scanCollection(collectionPath: string, collectionName: string, co
378
476
  }
379
477
 
380
478
  if (sources.length === 0) return null
479
+ return await buildCollectionDefinition(collectionPath, sources, collectionName, contentDir)
480
+ } catch {
481
+ return null
482
+ }
483
+ }
381
484
 
382
- const hasMd = sources.some(s => s.relPath.endsWith('.md'))
383
- const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
384
-
385
- const fieldMap = new Map<string, FieldObservation>()
386
- const allDirectives: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
387
- const entryInfos: CollectionEntryInfo[] = []
388
- let hasDraft = false
389
-
390
- const fileContents = await Promise.all(
391
- sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8')),
392
- )
393
-
394
- for (let i = 0; i < sources.length; i++) {
395
- const source = sources[i]!
396
- const content = fileContents[i]!
397
- const frontmatter = parseFrontmatter(content)
398
-
399
- const directives = parseFieldDirectives(content)
400
- for (const [key, value] of Object.entries(directives)) {
401
- if (!allDirectives[key]) {
402
- allDirectives[key] = value
403
- }
404
- }
405
-
406
- const entryInfo: CollectionEntryInfo = {
407
- slug: source.slug,
408
- sourcePath: path.join(contentDir, collectionName, source.relPath),
485
+ /** Convert a glob pattern (supports `*`, `**`, `?`, `{a,b}`) to an anchored RegExp. */
486
+ function globToRegExp(glob: string): RegExp {
487
+ let re = ''
488
+ for (let i = 0; i < glob.length; i++) {
489
+ const c = glob[i]!
490
+ if (c === '*') {
491
+ if (glob[i + 1] === '*') {
492
+ re += '.*'
493
+ i++
494
+ if (glob[i + 1] === '/') i++
495
+ } else {
496
+ re += '[^/]*'
409
497
  }
410
- if (frontmatter) {
411
- if (typeof frontmatter.title === 'string') {
412
- entryInfo.title = frontmatter.title
413
- }
414
- if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
415
- entryInfo.draft = true
416
- }
417
- entryInfo.data = frontmatter
498
+ } else if (c === '?') {
499
+ re += '[^/]'
500
+ } else if (c === '{') {
501
+ const end = glob.indexOf('}', i)
502
+ if (end === -1) {
503
+ re += '\\{'
504
+ } else {
505
+ const opts = glob.slice(i + 1, end).split(',').map(s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
506
+ re += `(?:${opts.join('|')})`
507
+ i = end
418
508
  }
419
- entryInfos.push(entryInfo)
420
-
421
- if (!frontmatter) continue
509
+ } else if ('.+^$()|[]\\'.includes(c)) {
510
+ re += `\\${c}`
511
+ } else {
512
+ re += c
513
+ }
514
+ }
515
+ return new RegExp(`^${re}$`)
516
+ }
422
517
 
423
- if (frontmatter.draft === true) hasDraft = true
424
- collectFieldObservations(fieldMap, frontmatter, sources.length)
518
+ /** Recursively list files under `dir`, returning forward-slash paths relative to `dir`. */
519
+ async function walkFiles(dir: string, prefix = ''): Promise<string[]> {
520
+ let dirEntries: Dirent[]
521
+ try {
522
+ dirEntries = await fs.readdir(dir, { withFileTypes: true })
523
+ } catch {
524
+ return []
525
+ }
526
+ const out: string[] = []
527
+ for (const entry of dirEntries) {
528
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
529
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
530
+ if (entry.isDirectory()) {
531
+ out.push(...await walkFiles(path.join(dir, entry.name), rel))
532
+ } else if (entry.isFile()) {
533
+ out.push(rel)
425
534
  }
535
+ }
536
+ return out
537
+ }
426
538
 
427
- const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
428
- supportsDraft: hasDraft,
429
- fileExtension,
430
- })
431
- assignFieldMetadata(def.fields, allDirectives)
432
- return def
539
+ /**
540
+ * Scan a collection declared in content config via a glob loader (base + pattern),
541
+ * which may share a base directory with another collection (nested layout).
542
+ * Runtime-agnostic: walks the filesystem and matches the glob (no Bun.Glob dependency).
543
+ */
544
+ async function scanGlobCollection(
545
+ collectionName: string,
546
+ baseRel: string,
547
+ pattern: string,
548
+ contentDir: string,
549
+ ): Promise<CollectionDefinition | null> {
550
+ try {
551
+ const absBase = path.join(getProjectRoot(), baseRel)
552
+ const matcher = globToRegExp(pattern)
553
+ const sources = (await walkFiles(absBase))
554
+ .filter(rel => (rel.endsWith('.md') || rel.endsWith('.mdx')) && matcher.test(rel))
555
+ .map(rel => ({ slug: rel.replace(/\.(md|mdx)$/, ''), relPath: rel }))
556
+
557
+ if (sources.length === 0) return null
558
+ return await buildCollectionDefinition(absBase, sources, collectionName, contentDir)
433
559
  } catch {
434
560
  return null
435
561
  }
@@ -456,17 +582,65 @@ function applyParsedConfig(
456
582
  for (const pf of parsedColl.fields) {
457
583
  const field = fieldsByName.get(pf.name)
458
584
  if (!field) continue
459
- if (pf.type) {
460
- field.type = pf.type
461
- if (pf.options) field.options = pf.options
462
- }
463
- if (pf.hints) field.hints = pf.hints
464
- if (pf.astroImage) field.astroImage = true
465
- field.required = pf.required
585
+ applyParsedFieldOverrides(field, pf)
466
586
  }
467
587
  }
468
588
  }
469
589
 
590
+ /**
591
+ * Apply parsed schema overrides to an inferred field, recursing into nested object/array fields.
592
+ *
593
+ * Note on schema-vs-inferred merging at nested levels: schema-declared sub-fields replace
594
+ * the inferred list rather than merging. Inferred-only sub-fields are *not* lost — the
595
+ * editor's `ObjectFields` recovers them via its `extraKeys` calculation (field value keys
596
+ * minus schemaNames), routes them through `FrontmatterField` (value-based auto-detect),
597
+ * and offers a remove button. Merging here would defeat that.
598
+ */
599
+ function applyParsedFieldOverrides(field: FieldDefinition, pf: ParsedField): void {
600
+ if (pf.type) {
601
+ field.type = pf.type
602
+ if (pf.options) field.options = pf.options
603
+ }
604
+ if (pf.itemType) field.itemType = pf.itemType
605
+ if (pf.hints) field.hints = pf.hints
606
+ if (pf.astroImage) field.astroImage = true
607
+ field.required = pf.required
608
+
609
+ if (pf.fields) {
610
+ const existingByName = new Map((field.fields ?? []).map(f => [f.name, f]))
611
+ field.fields = pf.fields.map((subPf) => {
612
+ const existing = existingByName.get(subPf.name)
613
+ if (existing) {
614
+ applyParsedFieldOverrides(existing, subPf)
615
+ return existing
616
+ }
617
+ return parsedFieldToFieldDefinition(subPf)
618
+ })
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Build a FieldDefinition from a parsed schema field when no inferred counterpart exists.
624
+ * Falls back to `'text'` when the parser couldn't pin a type — keeps the field visible
625
+ * and editable. Schema-declared-but-data-absent fields would otherwise vanish.
626
+ */
627
+ function parsedFieldToFieldDefinition(pf: ParsedField): FieldDefinition {
628
+ const fd: FieldDefinition = {
629
+ name: pf.name,
630
+ // A parsed field with nested children but no explicit type is necessarily an object.
631
+ // Otherwise default to 'text' so users can still fill in schema-declared fields
632
+ // whose helper the parser didn't recognize.
633
+ type: pf.type ?? (pf.fields ? 'object' : 'text'),
634
+ required: pf.required,
635
+ }
636
+ if (pf.options) fd.options = pf.options
637
+ if (pf.itemType) fd.itemType = pf.itemType
638
+ if (pf.hints) fd.hints = pf.hints
639
+ if (pf.astroImage) fd.astroImage = true
640
+ if (pf.fields) fd.fields = pf.fields.map(parsedFieldToFieldDefinition)
641
+ return fd
642
+ }
643
+
470
644
  /** Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries. */
471
645
  function applyCollectionOrderBy(
472
646
  collections: Record<string, CollectionDefinition>,
@@ -753,7 +927,7 @@ async function scanDataCollection(collectionPath: string, collectionName: string
753
927
  collectFieldObservations(fieldMap, data, sources.length)
754
928
  }
755
929
 
756
- return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
930
+ return assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
757
931
  type: 'data',
758
932
  fileExtension: ext,
759
933
  })
@@ -792,6 +966,20 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
792
966
 
793
967
  // Post-scan: apply schema-driven field config, detect references, derived fields, and ordering
794
968
  const parsed = await parseContentConfig()
969
+ for (const [collectionName, parsedCollection] of parsed) {
970
+ if (collections[collectionName]) continue
971
+ if (!parsedCollection.loaderBase || !parsedCollection.loaderPattern) continue
972
+ const definition = await scanGlobCollection(collectionName, parsedCollection.loaderBase, parsedCollection.loaderPattern, contentDir)
973
+ if (!definition) continue
974
+ // Nest under the collection that owns the shared base directory (e.g. jsem-otazky -> jsem),
975
+ // so the CMS browser can group it under its parent page instead of listing it flat.
976
+ const baseName = parsedCollection.loaderBase.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
977
+ if (baseName && baseName !== collectionName && collections[baseName]) {
978
+ definition.parentCollection = baseName
979
+ }
980
+ collections[collectionName] = definition
981
+ }
982
+
795
983
  applyParsedConfig(collections, parsed)
796
984
  detectReferenceFields(collections, parsed)
797
985
  detectDerivedHrefFields(collections)