@nuasite/cms 0.31.0 → 0.34.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 +5167 -5123
- package/package.json +2 -1
- package/src/astro-image-paths.ts +74 -0
- package/src/collection-scanner.ts +55 -334
- package/src/content-config-ast.ts +356 -0
- package/src/editor/components/frontmatter-fields.tsx +14 -3
- package/src/editor/components/frontmatter-sidebar.tsx +2 -0
- package/src/editor/components/image-overlay.tsx +16 -7
- package/src/editor/components/media-library.tsx +12 -1
- package/src/editor/constants.ts +3 -0
- package/src/editor/dom.ts +3 -0
- package/src/editor/editor.ts +27 -0
- package/src/editor/manifest.ts +37 -1
- package/src/editor/markdown-api.ts +5 -1
- package/src/editor/signals.ts +4 -0
- package/src/editor/types.ts +4 -1
- package/src/field-types.ts +15 -1
- package/src/handlers/api-routes.ts +22 -2
- package/src/handlers/astro-image-upload.ts +60 -0
- package/src/index.ts +1 -0
- package/src/migrate-astro-image.ts +116 -0
- package/src/source-finder/snippet-utils.ts +105 -7
- package/src/types.ts +11 -0
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.
|
|
17
|
+
"version": "0.34.0",
|
|
18
18
|
"module": "src/index.ts",
|
|
19
19
|
"types": "src/index.ts",
|
|
20
20
|
"type": "module",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"yaml": "^2.8.3"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
+
"@babel/types": "^7.29.0",
|
|
35
36
|
"@milkdown/core": "^7.20.0",
|
|
36
37
|
"@milkdown/ctx": "^7.20.0",
|
|
37
38
|
"@milkdown/plugin-listener": "^7.20.0",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const HUGO_INDEX_RE = /^index\.(md|mdx)$/i
|
|
6
|
+
|
|
7
|
+
/** True when the entry uses the Hugo-style `<slug>/index.md(x)` layout (vs. a flat `<slug>.md` file). */
|
|
8
|
+
export function isHugoStyleEntry(entryAbsPath: string): boolean {
|
|
9
|
+
return HUGO_INDEX_RE.test(path.basename(entryAbsPath))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** First 8 hex chars of a sha256 digest — enough entropy to disambiguate collision suffixes. */
|
|
13
|
+
export function shortContentHash(buf: Buffer): string {
|
|
14
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 8)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute the on-disk target for an Astro `image()` field's value.
|
|
19
|
+
*
|
|
20
|
+
* Layout rules:
|
|
21
|
+
* - Hugo-style entry (`<slug>/index.md`) → file goes inside the entry's directory, bare filename.
|
|
22
|
+
* - Flat entry (`<slug>.md`) → file goes next to the entry, prefixed with the slug
|
|
23
|
+
* so multiple entries don't collide on a shared filename.
|
|
24
|
+
*
|
|
25
|
+
* If a candidate slot already holds a file with identical bytes to `compareBuffer`,
|
|
26
|
+
* it's reused; otherwise the filename gets a content-hash suffix.
|
|
27
|
+
*/
|
|
28
|
+
export async function pickAstroImageTarget(args: {
|
|
29
|
+
entryAbsPath: string
|
|
30
|
+
slug: string
|
|
31
|
+
originalFilename: string
|
|
32
|
+
compareBuffer: Buffer
|
|
33
|
+
}): Promise<{ absPath: string; relPath: string }> {
|
|
34
|
+
const entryDir = path.dirname(args.entryAbsPath)
|
|
35
|
+
const isHugoStyle = isHugoStyleEntry(args.entryAbsPath)
|
|
36
|
+
|
|
37
|
+
// `originalFilename` may come from user-controlled multipart input — strip any
|
|
38
|
+
// directory components so a `../` segment can't escape the entry directory.
|
|
39
|
+
const safeFilename = path.basename(args.originalFilename)
|
|
40
|
+
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
|
|
41
|
+
throw new Error(`Invalid filename: ${args.originalFilename}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const baseName = isHugoStyle ? safeFilename : `${args.slug}-${safeFilename}`
|
|
45
|
+
const baseAbs = path.join(entryDir, baseName)
|
|
46
|
+
if (await isFreeOrMatching(baseAbs, args.compareBuffer)) {
|
|
47
|
+
return { absPath: baseAbs, relPath: `./${baseName}` }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hash = shortContentHash(args.compareBuffer)
|
|
51
|
+
const ext = path.extname(safeFilename)
|
|
52
|
+
const stem = path.basename(baseName, ext)
|
|
53
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
54
|
+
const suffix = attempt === 0 ? hash : `${hash}-${attempt}`
|
|
55
|
+
const candidateName = `${stem}-${suffix}${ext}`
|
|
56
|
+
const candidateAbs = path.join(entryDir, candidateName)
|
|
57
|
+
if (await isFreeOrMatching(candidateAbs, args.compareBuffer)) {
|
|
58
|
+
return { absPath: candidateAbs, relPath: `./${candidateName}` }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Could not pick a unique filename for ${safeFilename} in ${entryDir}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True if the slot is empty, or holds a file with identical bytes to `compareBuffer`. */
|
|
65
|
+
async function isFreeOrMatching(absPath: string, compareBuffer: Buffer): Promise<boolean> {
|
|
66
|
+
try {
|
|
67
|
+
const stat = await fs.stat(absPath)
|
|
68
|
+
if (stat.size !== compareBuffer.length) return false
|
|
69
|
+
const existing = await fs.readFile(absPath)
|
|
70
|
+
return compareBuffer.equals(existing)
|
|
71
|
+
} catch {
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
|
|
4
4
|
import { getProjectRoot } from './config'
|
|
5
|
+
import { parseContentConfig, type ParsedConfig } from './content-config-ast'
|
|
5
6
|
import { slugifyHref } from './shared'
|
|
6
|
-
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition,
|
|
7
|
+
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
|
|
7
8
|
|
|
8
9
|
/** Regex patterns for type inference */
|
|
9
10
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
|
|
@@ -416,162 +417,50 @@ async function scanCollection(collectionPath: string, collectionName: string, co
|
|
|
416
417
|
}
|
|
417
418
|
|
|
418
419
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
420
|
+
* Filter scanned fields to schema-only and apply per-field overrides (type, hints, required)
|
|
421
|
+
* in a single pass. Filtering must happen first since it can shrink `def.fields`.
|
|
421
422
|
*/
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
// Map variable names to collection names from exports
|
|
431
|
-
const varToName = new Map<string, string>()
|
|
432
|
-
const exportMatch = content.match(/export\s+const\s+collections\s*=\s*\{([\s\S]*?)\}/)
|
|
433
|
-
if (exportMatch) {
|
|
434
|
-
const pairs = exportMatch[1]!.matchAll(/(\w+)\s*:\s*(\w+)/g)
|
|
435
|
-
for (const m of pairs) {
|
|
436
|
-
varToName.set(m[2]!, m[1]!)
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Find schema block starts via regex, then extract bodies with brace counting
|
|
441
|
-
// to correctly handle nested objects like n.number({ min: 1, max: 100 })
|
|
442
|
-
const schemaStart = /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*(?:z|n)\.object\s*\(\s*\{/g
|
|
443
|
-
const blocks: Array<{ collectionName: string; schemaBody: string }> = []
|
|
444
|
-
|
|
445
|
-
let match
|
|
446
|
-
while ((match = schemaStart.exec(content)) !== null) {
|
|
447
|
-
const varName = match[1]
|
|
448
|
-
const collectionName = varName ? varToName.get(varName) : undefined
|
|
449
|
-
if (!collectionName) continue
|
|
450
|
-
|
|
451
|
-
// Brace-balanced extraction: the regex consumed the opening {,
|
|
452
|
-
// so start at depth 1 and scan forward for the matching }
|
|
453
|
-
const bodyStart = match.index + match[0].length
|
|
454
|
-
let depth = 1
|
|
455
|
-
let i = bodyStart
|
|
456
|
-
while (i < content.length && depth > 0) {
|
|
457
|
-
if (content[i] === '{') depth++
|
|
458
|
-
else if (content[i] === '}') depth--
|
|
459
|
-
i++
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (depth === 0) {
|
|
463
|
-
// i is one past the matching }, so body is [bodyStart, i-1)
|
|
464
|
-
blocks.push({ collectionName, schemaBody: content.slice(bodyStart, i - 1) })
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (blocks.length > 0) return blocks
|
|
469
|
-
} catch {
|
|
470
|
-
// File doesn't exist, try next
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
return []
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Parse the Astro content config file to extract explicit reference() declarations.
|
|
478
|
-
* Returns a map: collectionName → { fieldName → { target, isArray } }
|
|
479
|
-
*/
|
|
480
|
-
function parseContentConfigReferences(
|
|
481
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
482
|
-
): Map<string, Map<string, { target: string; isArray: boolean }>> {
|
|
483
|
-
const result = new Map<string, Map<string, { target: string; isArray: boolean }>>()
|
|
484
|
-
|
|
485
|
-
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
486
|
-
const fields = new Map<string, { target: string; isArray: boolean }>()
|
|
487
|
-
const fieldRefs = schemaBody.matchAll(/(\w+)\s*:\s*(z\.array\s*\(\s*)?reference\s*\(\s*['"](\w+)['"]\s*\)/g)
|
|
488
|
-
for (const m of fieldRefs) {
|
|
489
|
-
fields.set(m[1]!, { target: m[3]!, isArray: !!m[2] })
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (fields.size > 0) {
|
|
493
|
-
result.set(collectionName, fields)
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
return result
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/** Valid field type names exported by `n` helper from @nuasite/cms */
|
|
500
|
-
const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'tel', 'color', 'date', 'datetime', 'time', 'textarea'])
|
|
423
|
+
function applyParsedConfig(
|
|
424
|
+
collections: Record<string, CollectionDefinition>,
|
|
425
|
+
parsed: ParsedConfig,
|
|
426
|
+
): void {
|
|
427
|
+
for (const [collectionName, parsedColl] of parsed) {
|
|
428
|
+
const def = collections[collectionName]
|
|
429
|
+
if (!def) continue
|
|
501
430
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
* - `z.enum([...])` for select options
|
|
506
|
-
*
|
|
507
|
-
* Returns a map: collectionName → fieldName → { type, options? }
|
|
508
|
-
*/
|
|
509
|
-
function parseContentConfigFieldTypes(
|
|
510
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
511
|
-
): Map<string, Map<string, { type: FieldType; options?: string[] }>> {
|
|
512
|
-
const result = new Map<string, Map<string, { type: FieldType; options?: string[] }>>()
|
|
513
|
-
|
|
514
|
-
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
515
|
-
const fields = new Map<string, { type: FieldType; options?: string[] }>()
|
|
516
|
-
|
|
517
|
-
// Detect n.image(), n.url(), etc.
|
|
518
|
-
const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*n\.(\w+)/g)
|
|
519
|
-
for (const m of fieldHelpers) {
|
|
520
|
-
const fieldName = m[1]!
|
|
521
|
-
const helperName = m[2]!
|
|
522
|
-
if (FIELD_HELPER_TYPES.has(helperName)) {
|
|
523
|
-
fields.set(fieldName, { type: helperName as FieldType })
|
|
524
|
-
}
|
|
431
|
+
if (parsedColl.fields.length > 0) {
|
|
432
|
+
const schemaNames = new Set(parsedColl.fields.map(f => f.name))
|
|
433
|
+
def.fields = def.fields.filter(f => schemaNames.has(f.name))
|
|
525
434
|
}
|
|
526
435
|
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
fields.set(fieldName, { type: 'select', options })
|
|
436
|
+
const fieldsByName = new Map(def.fields.map(f => [f.name, f]))
|
|
437
|
+
for (const pf of parsedColl.fields) {
|
|
438
|
+
const field = fieldsByName.get(pf.name)
|
|
439
|
+
if (!field) continue
|
|
440
|
+
if (pf.type) {
|
|
441
|
+
field.type = pf.type
|
|
442
|
+
if (pf.options) field.options = pf.options
|
|
535
443
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
result.set(collectionName, fields)
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
return result
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Parse the content config to find `.orderBy('asc'|'desc')` markers on fields.
|
|
547
|
-
* Matches patterns like `fieldName: n.number().orderBy('asc')`.
|
|
548
|
-
* Returns a map: collectionName → { field, direction }.
|
|
549
|
-
*/
|
|
550
|
-
function parseContentConfigOrderBy(
|
|
551
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
552
|
-
): Map<string, { field: string; direction: 'asc' | 'desc' }> {
|
|
553
|
-
const result = new Map<string, { field: string; direction: 'asc' | 'desc' }>()
|
|
554
|
-
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
555
|
-
const match = schemaBody.match(/(\w+)\s*:.*\.orderBy\s*\(\s*(?:['"](\w+)['"])?\s*\)/)
|
|
556
|
-
if (match) {
|
|
557
|
-
const direction = match[2] === 'desc' ? 'desc' as const : 'asc' as const
|
|
558
|
-
result.set(collectionName, { field: match[1]!, direction })
|
|
444
|
+
if (pf.hints) field.hints = pf.hints
|
|
445
|
+
if (pf.astroImage) field.astroImage = true
|
|
446
|
+
field.required = pf.required
|
|
559
447
|
}
|
|
560
448
|
}
|
|
561
|
-
return result
|
|
562
449
|
}
|
|
563
450
|
|
|
564
|
-
/**
|
|
565
|
-
* Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries.
|
|
566
|
-
*/
|
|
451
|
+
/** Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries. */
|
|
567
452
|
function applyCollectionOrderBy(
|
|
568
453
|
collections: Record<string, CollectionDefinition>,
|
|
569
|
-
|
|
454
|
+
parsed: ParsedConfig,
|
|
570
455
|
): void {
|
|
571
|
-
const
|
|
572
|
-
|
|
456
|
+
for (const [collectionName, parsedColl] of parsed) {
|
|
457
|
+
const orderField = parsedColl.fields.find(f => f.orderBy)
|
|
458
|
+
if (!orderField?.orderBy) continue
|
|
573
459
|
const def = collections[collectionName]
|
|
574
460
|
if (!def) continue
|
|
461
|
+
|
|
462
|
+
const fieldName = orderField.name
|
|
463
|
+
const direction = orderField.orderBy.direction
|
|
575
464
|
def.orderBy = fieldName
|
|
576
465
|
def.orderDirection = direction
|
|
577
466
|
if (def.entries && def.entries.length > 1) {
|
|
@@ -590,200 +479,35 @@ function applyCollectionOrderBy(
|
|
|
590
479
|
}
|
|
591
480
|
}
|
|
592
481
|
|
|
593
|
-
/** Match `fieldName:` patterns at the start of lines within a schema body. */
|
|
594
|
-
const SCHEMA_FIELD_PATTERN = /^\s*(\w+)\s*:/gm
|
|
595
|
-
|
|
596
482
|
/**
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
* from appearing in the CMS editor.
|
|
483
|
+
* Detect reference fields. Prefers explicit `reference()` declarations from the content
|
|
484
|
+
* config; if none are found anywhere, falls back to heuristic slug matching.
|
|
600
485
|
*/
|
|
601
|
-
function
|
|
486
|
+
function detectReferenceFields(
|
|
602
487
|
collections: Record<string, CollectionDefinition>,
|
|
603
|
-
|
|
488
|
+
parsed: ParsedConfig,
|
|
604
489
|
): void {
|
|
605
|
-
|
|
490
|
+
let appliedAny = false
|
|
491
|
+
for (const [collectionName, parsedColl] of parsed) {
|
|
606
492
|
const def = collections[collectionName]
|
|
607
493
|
if (!def) continue
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
if (schemaNames.size === 0) continue
|
|
613
|
-
def.fields = def.fields.filter(f => schemaNames.has(f.name))
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Apply a parsed per-field config map to scanned collection definitions.
|
|
619
|
-
*/
|
|
620
|
-
function applyPerFieldConfig<T>(
|
|
621
|
-
collections: Record<string, CollectionDefinition>,
|
|
622
|
-
configMap: Map<string, Map<string, T>>,
|
|
623
|
-
apply: (field: FieldDefinition, value: T) => void,
|
|
624
|
-
): void {
|
|
625
|
-
for (const [collectionName, fieldMap] of configMap) {
|
|
626
|
-
const def = collections[collectionName]
|
|
627
|
-
if (!def) continue
|
|
628
|
-
for (const [fieldName, value] of fieldMap) {
|
|
629
|
-
const field = def.fields.find(f => f.name === fieldName)
|
|
494
|
+
for (const pf of parsedColl.fields) {
|
|
495
|
+
if (!pf.reference) continue
|
|
496
|
+
const field = def.fields.find(f => f.name === pf.name)
|
|
630
497
|
if (!field) continue
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
function applyConfigFieldTypes(
|
|
638
|
-
collections: Record<string, CollectionDefinition>,
|
|
639
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
640
|
-
): void {
|
|
641
|
-
applyPerFieldConfig(collections, parseContentConfigFieldTypes(schemaBlocks), (field, override) => {
|
|
642
|
-
field.type = override.type
|
|
643
|
-
if (override.options) field.options = override.options
|
|
644
|
-
})
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** All recognized hint keys */
|
|
648
|
-
const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
|
|
649
|
-
/** Subset of hint keys that take numeric values */
|
|
650
|
-
const NUMERIC_HINT_KEYS = new Set(['min', 'max', 'step', 'maxLength', 'minLength', 'rows'])
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Parse `n.type({ key: value, ... })` options objects from schema blocks.
|
|
654
|
-
* Returns a map: collectionName → fieldName → FieldHints.
|
|
655
|
-
*/
|
|
656
|
-
function parseContentConfigFieldHints(
|
|
657
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
658
|
-
): Map<string, Map<string, FieldHints>> {
|
|
659
|
-
const result = new Map<string, Map<string, FieldHints>>()
|
|
660
|
-
|
|
661
|
-
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
662
|
-
const fields = new Map<string, FieldHints>()
|
|
663
|
-
|
|
664
|
-
// Match: fieldName: n.helperName({ ...options })
|
|
665
|
-
const fieldMatches = schemaBody.matchAll(/(\w+)\s*:\s*n\.\w+\s*\(\s*\{([\s\S]*?)}\s*\)/g)
|
|
666
|
-
for (const m of fieldMatches) {
|
|
667
|
-
const fieldName = m[1]!
|
|
668
|
-
const optionsBody = m[2]!
|
|
669
|
-
const raw: Record<string, string | number> = {}
|
|
670
|
-
|
|
671
|
-
// Extract key-value pairs from the options body
|
|
672
|
-
const kvMatches = optionsBody.matchAll(/(\w+)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?[\d.]+))/g)
|
|
673
|
-
for (const kv of kvMatches) {
|
|
674
|
-
const key = kv[1]!
|
|
675
|
-
if (!VALID_HINT_KEYS.has(key)) continue
|
|
676
|
-
const strValue = kv[2] ?? kv[3]
|
|
677
|
-
const numValue = kv[4]
|
|
678
|
-
|
|
679
|
-
if (numValue != null && NUMERIC_HINT_KEYS.has(key)) {
|
|
680
|
-
raw[key] = Number(numValue)
|
|
681
|
-
} else if (strValue != null) {
|
|
682
|
-
if (NUMERIC_HINT_KEYS.has(key)) {
|
|
683
|
-
const parsed = Number(strValue)
|
|
684
|
-
raw[key] = Number.isNaN(parsed) ? strValue : parsed
|
|
685
|
-
} else {
|
|
686
|
-
raw[key] = strValue
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
const hints = raw as FieldHints
|
|
691
|
-
|
|
692
|
-
if (Object.keys(hints).length > 0) {
|
|
693
|
-
fields.set(fieldName, hints)
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
if (fields.size > 0) {
|
|
698
|
-
result.set(collectionName, fields)
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
return result
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Parse required/optional status from schema blocks.
|
|
706
|
-
* In Zod, fields are required by default. `.optional()`, `.nullable()`, and `.default(...)` make them not required.
|
|
707
|
-
*/
|
|
708
|
-
function parseContentConfigRequiredFields(
|
|
709
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
710
|
-
): Map<string, Map<string, boolean>> {
|
|
711
|
-
const result = new Map<string, Map<string, boolean>>()
|
|
712
|
-
|
|
713
|
-
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
714
|
-
const fields = new Map<string, boolean>()
|
|
715
|
-
|
|
716
|
-
const fieldMatches = [...schemaBody.matchAll(SCHEMA_FIELD_PATTERN)]
|
|
717
|
-
for (let i = 0; i < fieldMatches.length; i++) {
|
|
718
|
-
const fieldName = fieldMatches[i]![1]!
|
|
719
|
-
const start = fieldMatches[i]!.index!
|
|
720
|
-
const end = i + 1 < fieldMatches.length ? fieldMatches[i + 1]!.index! : schemaBody.length
|
|
721
|
-
const fieldSource = schemaBody.slice(start, end)
|
|
722
|
-
|
|
723
|
-
const isOptional = /\.(optional|nullable|default)\s*\(/.test(fieldSource)
|
|
724
|
-
fields.set(fieldName, !isOptional)
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
if (fields.size > 0) {
|
|
728
|
-
result.set(collectionName, fields)
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
return result
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/** Apply field hints from content config parsing to scanned collections. */
|
|
735
|
-
function applyConfigFieldHints(
|
|
736
|
-
collections: Record<string, CollectionDefinition>,
|
|
737
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
738
|
-
): void {
|
|
739
|
-
applyPerFieldConfig(collections, parseContentConfigFieldHints(schemaBlocks), (field, hints) => {
|
|
740
|
-
field.hints = hints
|
|
741
|
-
})
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/** Apply required/optional status from content config to scanned collections. */
|
|
745
|
-
function applyConfigRequiredFields(
|
|
746
|
-
collections: Record<string, CollectionDefinition>,
|
|
747
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
748
|
-
): void {
|
|
749
|
-
applyPerFieldConfig(collections, parseContentConfigRequiredFields(schemaBlocks), (field, required) => {
|
|
750
|
-
field.required = required
|
|
751
|
-
})
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* After all collections are scanned, detect reference fields.
|
|
756
|
-
* Prefers explicit reference() declarations from the content config file.
|
|
757
|
-
* Falls back to heuristic slug matching when no config is available.
|
|
758
|
-
*/
|
|
759
|
-
async function detectReferenceFields(
|
|
760
|
-
collections: Record<string, CollectionDefinition>,
|
|
761
|
-
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
762
|
-
): Promise<void> {
|
|
763
|
-
// Try parsing the content config first — this is the source of truth
|
|
764
|
-
const configRefs = parseContentConfigReferences(schemaBlocks)
|
|
765
|
-
if (configRefs.size > 0) {
|
|
766
|
-
for (const [collectionName, fieldRefs] of configRefs) {
|
|
767
|
-
const def = collections[collectionName]
|
|
768
|
-
if (!def) continue
|
|
769
|
-
for (const [fieldName, ref] of fieldRefs) {
|
|
770
|
-
const field = def.fields.find(f => f.name === fieldName)
|
|
771
|
-
if (!field) continue
|
|
772
|
-
if (ref.isArray) {
|
|
773
|
-
field.type = 'array'
|
|
774
|
-
field.itemType = 'reference'
|
|
775
|
-
} else {
|
|
776
|
-
field.type = 'reference'
|
|
777
|
-
}
|
|
778
|
-
field.collection = ref.target
|
|
779
|
-
field.options = undefined
|
|
498
|
+
appliedAny = true
|
|
499
|
+
if (pf.reference.isArray) {
|
|
500
|
+
field.type = 'array'
|
|
501
|
+
field.itemType = 'reference'
|
|
502
|
+
} else {
|
|
503
|
+
field.type = 'reference'
|
|
780
504
|
}
|
|
505
|
+
field.collection = pf.reference.target
|
|
506
|
+
field.options = undefined
|
|
781
507
|
}
|
|
782
|
-
return
|
|
783
508
|
}
|
|
784
509
|
|
|
785
|
-
|
|
786
|
-
detectReferenceFieldsBySlugMatch(collections)
|
|
510
|
+
if (!appliedAny) detectReferenceFieldsBySlugMatch(collections)
|
|
787
511
|
}
|
|
788
512
|
|
|
789
513
|
function detectReferenceFieldsBySlugMatch(collections: Record<string, CollectionDefinition>): void {
|
|
@@ -1020,15 +744,12 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
|
|
|
1020
744
|
// Content directory doesn't exist or isn't readable
|
|
1021
745
|
}
|
|
1022
746
|
|
|
1023
|
-
// Post-scan: apply
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
applyConfigFieldHints(collections, schemaBlocks)
|
|
1028
|
-
applyConfigRequiredFields(collections, schemaBlocks)
|
|
1029
|
-
await detectReferenceFields(collections, schemaBlocks)
|
|
747
|
+
// Post-scan: apply schema-driven field config, detect references, derived fields, and ordering
|
|
748
|
+
const parsed = await parseContentConfig()
|
|
749
|
+
applyParsedConfig(collections, parsed)
|
|
750
|
+
detectReferenceFields(collections, parsed)
|
|
1030
751
|
detectDerivedHrefFields(collections)
|
|
1031
|
-
applyCollectionOrderBy(collections,
|
|
752
|
+
applyCollectionOrderBy(collections, parsed)
|
|
1032
753
|
|
|
1033
754
|
return collections
|
|
1034
755
|
}
|