@nuasite/cms 0.46.4 → 0.47.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.
@@ -1,990 +0,0 @@
1
- import type { Dirent } from 'node:fs'
2
- import fs from 'node:fs/promises'
3
- import path from 'node:path'
4
- import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
5
- import { getProjectRoot } from './config'
6
- import { parseContentConfig, type ParsedConfig, type ParsedField } from './content-config-ast'
7
- import { slugifyHref } from './shared'
8
- import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
9
-
10
- /** Regex patterns for type inference */
11
- const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
12
- const URL_PATTERN = /^(https?:\/\/|\/)/
13
- const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|avif)$/i
14
-
15
- /** Maximum unique values before treating as free-form text instead of select */
16
- const MAX_SELECT_OPTIONS = 10
17
-
18
- /** Minimum length for textarea detection */
19
- const TEXTAREA_MIN_LENGTH = 200
20
-
21
- /** Field names that default to sidebar position */
22
- const SIDEBAR_FIELD_NAMES = new Set([
23
- 'title',
24
- 'date',
25
- 'pubdate',
26
- 'publishdate',
27
- 'draft',
28
- 'image',
29
- 'featuredimage',
30
- 'cover',
31
- 'coverimage',
32
- 'thumbnail',
33
- 'author',
34
- ])
35
-
36
- /** Matches `@position <value>` or `@group <value>` in YAML comment text (# already stripped by parser) */
37
- const DIRECTIVE_PATTERN = /^\s*@(position|group)\s+(.+)$/
38
-
39
- /** Field names that should never be inferred as select (always free-text) */
40
- const FREE_TEXT_FIELD_NAMES = new Set([
41
- 'title',
42
- 'name',
43
- 'description',
44
- 'summary',
45
- 'excerpt',
46
- 'subtitle',
47
- 'heading',
48
- 'headline',
49
- 'slug',
50
- 'alt',
51
- 'caption',
52
- ])
53
-
54
- /** Normalized names (lowercased, underscores/hyphens stripped) that mark a field as the publish toggle. */
55
- const PUBLISH_TOGGLE_NAMES = new Set(['draft', 'isdraft', 'published', 'ispublished', 'unpublished'])
56
-
57
- /** Normalized names that mark a field as the publish/release date anchor. */
58
- const PUBLISH_DATE_NAMES = new Set([
59
- 'date',
60
- 'pubdate',
61
- 'publishdate',
62
- 'publisheddate',
63
- 'publishedate',
64
- 'publishedat',
65
- 'datepublished',
66
- ])
67
-
68
- /** Normalize a field name for case- and separator-insensitive matching against the *_NAMES sets above. */
69
- function normalizeFieldName(name: string): string {
70
- return name.toLowerCase().replace(/[_-]/g, '')
71
- }
72
-
73
- /**
74
- * Observed values for a single field across multiple files
75
- */
76
- interface FieldObservation {
77
- name: string
78
- values: unknown[]
79
- presentCount: number
80
- totalEntries: number
81
- }
82
-
83
- const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/
84
-
85
- function extractFrontmatterBlock(content: string): string | null {
86
- const match = content.match(FRONTMATTER_PATTERN)
87
- return match?.[1] ?? null
88
- }
89
-
90
- function parseFrontmatter(content: string): Record<string, unknown> | null {
91
- const block = extractFrontmatterBlock(content)
92
- if (!block) return null
93
- return parseYaml(block) as Record<string, unknown> | null
94
- }
95
-
96
- /**
97
- * Parse @position and @group comment directives from raw YAML frontmatter.
98
- * Uses the YAML AST which preserves comments via `commentBefore` on nodes.
99
- */
100
- function parseFieldDirectives(content: string): Record<string, { position?: 'sidebar' | 'header'; group?: string }> {
101
- const block = extractFrontmatterBlock(content)
102
- if (!block) return {}
103
-
104
- const doc = parseDocument(block)
105
- if (!isMap(doc.contents)) return {}
106
-
107
- const result: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
108
-
109
- for (const pair of doc.contents.items) {
110
- if (!isPair(pair) || !isScalar(pair.key)) continue
111
- const comment = (pair.key as any).commentBefore as string | undefined
112
- if (!comment) continue
113
-
114
- const directives: { position?: 'sidebar' | 'header'; group?: string } = {}
115
- for (const line of comment.split('\n')) {
116
- const match = line.trim().match(DIRECTIVE_PATTERN)
117
- if (!match) continue
118
- const [, dirKey, dirValue] = match
119
- if (dirKey === 'position' && (dirValue === 'sidebar' || dirValue === 'header')) {
120
- directives.position = dirValue
121
- } else if (dirKey === 'group' && dirValue) {
122
- directives.group = dirValue.trim()
123
- }
124
- }
125
-
126
- if (directives.position || directives.group) {
127
- result[String(pair.key.value)] = directives
128
- }
129
- }
130
-
131
- return result
132
- }
133
-
134
- /**
135
- * Assign default positions to fields based on field name heuristics,
136
- * then overlay frontmatter comment directives.
137
- */
138
- function assignFieldMetadata(
139
- fields: FieldDefinition[],
140
- directives: Record<string, { position?: 'sidebar' | 'header'; group?: string }>,
141
- ): void {
142
- for (const field of fields) {
143
- // Scanner defaults: well-known fields go to sidebar
144
- if (SIDEBAR_FIELD_NAMES.has(normalizeFieldName(field.name)) || field.type === 'image' || field.type === 'boolean') {
145
- field.position = 'sidebar'
146
- } else {
147
- field.position = 'header'
148
- }
149
-
150
- // Overlay frontmatter comment directives
151
- const directive = directives[field.name]
152
- if (directive) {
153
- if (directive.position) field.position = directive.position
154
- if (directive.group) field.group = directive.group
155
- }
156
- }
157
- }
158
-
159
- /**
160
- * Infer the field type from a value
161
- */
162
- function inferFieldType(value: unknown, key: string): FieldType {
163
- if (value === null || value === undefined) {
164
- return 'text'
165
- }
166
-
167
- if (typeof value === 'boolean') {
168
- return 'boolean'
169
- }
170
-
171
- if (typeof value === 'number') {
172
- return 'number'
173
- }
174
-
175
- if (Array.isArray(value)) {
176
- return 'array'
177
- }
178
-
179
- if (typeof value === 'object') {
180
- return 'object'
181
- }
182
-
183
- if (typeof value === 'string') {
184
- // Check for date pattern
185
- if (DATE_PATTERN.test(value)) {
186
- return 'date'
187
- }
188
-
189
- // Check for image paths
190
- if (IMAGE_EXTENSIONS.test(value)) {
191
- return 'image'
192
- }
193
-
194
- // Check for image-specific field names (exact word boundaries, not substrings)
195
- const lowerKey = key.toLowerCase()
196
- if (/(?:^|[_-])(?:image|thumbnail|cover|avatar|logo|icon|banner|photo)(?:$|[_-])/.test(lowerKey)) {
197
- return 'image'
198
- }
199
-
200
- // Check for URLs
201
- if (URL_PATTERN.test(value)) {
202
- return 'url'
203
- }
204
-
205
- // Check for textarea (long text or contains newlines)
206
- if (value.includes('\n') || value.length > TEXTAREA_MIN_LENGTH) {
207
- return 'textarea'
208
- }
209
-
210
- return 'text'
211
- }
212
-
213
- return 'text'
214
- }
215
-
216
- /**
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.
220
- */
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 []
225
- const fields: FieldDefinition[] = []
226
-
227
- for (const obs of observations) {
228
- const nonNullValues = obs.values.filter(v => v !== null && v !== undefined)
229
- if (nonNullValues.length === 0) continue
230
-
231
- // Determine type by consensus (most common inferred type)
232
- const typeCounts = new Map<FieldType, number>()
233
- for (const value of nonNullValues) {
234
- const type = inferFieldType(value, obs.name)
235
- typeCounts.set(type, (typeCounts.get(type) || 0) + 1)
236
- }
237
-
238
- // Get most common type
239
- let fieldType: FieldType = 'text'
240
- let maxCount = 0
241
- for (const [type, count] of typeCounts) {
242
- if (count > maxCount) {
243
- maxCount = count
244
- fieldType = type
245
- }
246
- }
247
-
248
- const field: FieldDefinition = {
249
- name: obs.name,
250
- type: fieldType,
251
- required: obs.presentCount === obs.totalEntries,
252
- examples: nonNullValues.slice(0, 3),
253
- }
254
-
255
- // For text fields, check if we should treat as select (limited unique values)
256
- if (fieldType === 'text' && !FREE_TEXT_FIELD_NAMES.has(normalizeFieldName(obs.name))) {
257
- const uniqueValues = [...new Set(nonNullValues.map(v => String(v)))]
258
- const uniqueRatio = uniqueValues.length / nonNullValues.length
259
- // Only treat as select if unique values are limited AND not nearly all unique
260
- // (a high unique ratio means entries have distinct values, indicating free-text)
261
- if (uniqueValues.length > 0 && uniqueValues.length <= MAX_SELECT_OPTIONS && nonNullValues.length >= 2 && uniqueRatio <= 0.8) {
262
- field.type = 'select'
263
- field.options = uniqueValues.sort()
264
- }
265
- }
266
-
267
- // For arrays, try to infer item type
268
- if (fieldType === 'array') {
269
- const allItems = nonNullValues.flatMap(v => (Array.isArray(v) ? v : []))
270
- if (allItems.length > 0) {
271
- const itemType = inferFieldType(allItems[0], obs.name)
272
- field.itemType = itemType
273
-
274
- // Check if array items should be select
275
- if (itemType === 'text') {
276
- const uniqueItems = [...new Set(allItems.map(v => String(v)))]
277
- if (uniqueItems.length <= MAX_SELECT_OPTIONS * 2) {
278
- field.options = uniqueItems.sort()
279
- }
280
- }
281
-
282
- // Infer sub-field definitions for array-of-objects
283
- if (itemType === 'object') {
284
- const objectItems = allItems.filter(
285
- (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v),
286
- )
287
- if (objectItems.length > 0) {
288
- const subFieldMap = new Map<string, FieldObservation>()
289
- for (const item of objectItems) {
290
- collectFieldObservations(subFieldMap, item, objectItems.length)
291
- }
292
- field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
293
- }
294
- }
295
- }
296
- }
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
-
312
- fields.push(field)
313
- }
314
-
315
- return fields
316
- }
317
-
318
- function collectFieldObservations(
319
- fieldMap: Map<string, FieldObservation>,
320
- data: Record<string, unknown>,
321
- totalEntries: number,
322
- ): void {
323
- for (const [key, value] of Object.entries(data)) {
324
- let obs = fieldMap.get(key)
325
- if (!obs) {
326
- obs = { name: key, values: [], presentCount: 0, totalEntries }
327
- fieldMap.set(key, obs)
328
- }
329
- obs.values.push(value)
330
- obs.presentCount++
331
- }
332
- }
333
-
334
- function assembleCollectionDefinition(
335
- collectionName: string,
336
- contentDir: string,
337
- fieldMap: Map<string, FieldObservation>,
338
- entryInfos: CollectionEntryInfo[],
339
- entryCount: number,
340
- extra: Partial<CollectionDefinition>,
341
- ): CollectionDefinition {
342
- for (const obs of fieldMap.values()) {
343
- obs.totalEntries = entryCount
344
- }
345
-
346
- entryInfos.sort((a, b) => (a.title ?? a.slug).localeCompare(b.title ?? b.slug))
347
-
348
- const fields = mergeFieldObservations(Array.from(fieldMap.values()))
349
- const label = collectionName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
350
-
351
- return {
352
- name: collectionName,
353
- label,
354
- path: path.join(contentDir, collectionName),
355
- entryCount,
356
- fields,
357
- fileExtension: 'md',
358
- entries: entryInfos,
359
- ...extra,
360
- }
361
- }
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
-
441
- /**
442
- * Scan a single collection directory and infer its schema
443
- */
444
- async function scanCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
445
- try {
446
- const dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
447
-
448
- const sources: Array<{ slug: string; relPath: string }> = []
449
- const takenSlugs = new Set<string>()
450
-
451
- for (const entry of dirEntries) {
452
- if (!entry.isFile()) continue
453
- if (!entry.name.endsWith('.md') && !entry.name.endsWith('.mdx')) continue
454
- const slug = entry.name.replace(/\.(md|mdx)$/, '')
455
- sources.push({ slug, relPath: entry.name })
456
- takenSlugs.add(slug)
457
- }
458
-
459
- // Hugo-style layout: <slug>/index.md(x). Flat files win on slug conflict.
460
- const subdirs = dirEntries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
461
- const indexLookups = await Promise.all(subdirs.map(async dir => {
462
- if (takenSlugs.has(dir.name)) return null
463
- for (const ext of ['md', 'mdx'] as const) {
464
- const relPath = path.join(dir.name, `index.${ext}`)
465
- try {
466
- await fs.access(path.join(collectionPath, relPath))
467
- return { slug: dir.name, relPath }
468
- } catch {
469
- // try next extension
470
- }
471
- }
472
- return null
473
- }))
474
- for (const entry of indexLookups) {
475
- if (entry) sources.push(entry)
476
- }
477
-
478
- if (sources.length === 0) return null
479
- return await buildCollectionDefinition(collectionPath, sources, collectionName, contentDir)
480
- } catch {
481
- return null
482
- }
483
- }
484
-
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 += '[^/]*'
497
- }
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
508
- }
509
- } else if ('.+^$()|[]\\'.includes(c)) {
510
- re += `\\${c}`
511
- } else {
512
- re += c
513
- }
514
- }
515
- return new RegExp(`^${re}$`)
516
- }
517
-
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)
534
- }
535
- }
536
- return out
537
- }
538
-
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)
559
- } catch {
560
- return null
561
- }
562
- }
563
-
564
- /**
565
- * Filter scanned fields to schema-only and apply per-field overrides (type, hints, required)
566
- * in a single pass. Filtering must happen first since it can shrink `def.fields`.
567
- */
568
- function applyParsedConfig(
569
- collections: Record<string, CollectionDefinition>,
570
- parsed: ParsedConfig,
571
- ): void {
572
- for (const [collectionName, parsedColl] of parsed) {
573
- const def = collections[collectionName]
574
- if (!def) continue
575
-
576
- if (parsedColl.fields.length > 0) {
577
- const schemaNames = new Set(parsedColl.fields.map(f => f.name))
578
- def.fields = def.fields.filter(f => schemaNames.has(f.name))
579
- }
580
-
581
- const fieldsByName = new Map(def.fields.map(f => [f.name, f]))
582
- for (const pf of parsedColl.fields) {
583
- const field = fieldsByName.get(pf.name)
584
- if (!field) continue
585
- applyParsedFieldOverrides(field, pf)
586
- }
587
- }
588
- }
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
-
644
- /** Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries. */
645
- function applyCollectionOrderBy(
646
- collections: Record<string, CollectionDefinition>,
647
- parsed: ParsedConfig,
648
- ): void {
649
- for (const [collectionName, parsedColl] of parsed) {
650
- const orderField = parsedColl.fields.find(f => f.orderBy)
651
- if (!orderField?.orderBy) continue
652
- const def = collections[collectionName]
653
- if (!def) continue
654
-
655
- const fieldName = orderField.name
656
- const direction = orderField.orderBy.direction
657
- def.orderBy = fieldName
658
- def.orderDirection = direction
659
- if (def.entries && def.entries.length > 1) {
660
- const dir = direction === 'desc' ? -1 : 1
661
- def.entries.sort((a, b) => {
662
- const aVal = a.data?.[fieldName]
663
- const bVal = b.data?.[fieldName]
664
- if (aVal == null && bVal == null) return 0
665
- if (aVal == null) return 1
666
- if (bVal == null) return -1
667
- if (typeof aVal === 'number' && typeof bVal === 'number') return (aVal - bVal) * dir
668
- if (aVal instanceof Date && bVal instanceof Date) return (aVal.getTime() - bVal.getTime()) * dir
669
- return String(aVal).localeCompare(String(bVal)) * dir
670
- })
671
- }
672
- }
673
- }
674
-
675
- /**
676
- * Detect reference fields. Prefers explicit `reference()` declarations from the content
677
- * config; if none are found anywhere, falls back to heuristic slug matching.
678
- */
679
- function detectReferenceFields(
680
- collections: Record<string, CollectionDefinition>,
681
- parsed: ParsedConfig,
682
- ): void {
683
- let appliedAny = false
684
- for (const [collectionName, parsedColl] of parsed) {
685
- const def = collections[collectionName]
686
- if (!def) continue
687
- for (const pf of parsedColl.fields) {
688
- if (!pf.reference) continue
689
- const field = def.fields.find(f => f.name === pf.name)
690
- if (!field) continue
691
- appliedAny = true
692
- if (pf.reference.isArray) {
693
- field.type = 'array'
694
- field.itemType = 'reference'
695
- } else {
696
- field.type = 'reference'
697
- }
698
- field.collection = pf.reference.target
699
- field.options = undefined
700
- }
701
- }
702
-
703
- if (!appliedAny) detectReferenceFieldsBySlugMatch(collections)
704
- }
705
-
706
- function detectReferenceFieldsBySlugMatch(collections: Record<string, CollectionDefinition>): void {
707
- const collectionSlugs = new Map<string, Set<string>>()
708
- for (const [name, def] of Object.entries(collections)) {
709
- if (def.entries && def.entries.length > 0) {
710
- collectionSlugs.set(name, new Set(def.entries.map(e => e.slug)))
711
- }
712
- }
713
-
714
- for (const [collectionName, def] of Object.entries(collections)) {
715
- for (const field of def.fields) {
716
- if ((field.type === 'text' || field.type === 'select') && field.examples) {
717
- const stringExamples = field.examples.filter((v): v is string => typeof v === 'string')
718
- if (stringExamples.length === 0) continue
719
-
720
- // Find all candidate collections where all examples match slugs
721
- const candidates: Array<{ name: string; slugs: Set<string> }> = []
722
- for (const [targetName, slugs] of collectionSlugs) {
723
- if (targetName === collectionName) continue
724
- const matchCount = stringExamples.filter(v => slugs.has(v)).length
725
- if (matchCount > 0 && matchCount === stringExamples.length) {
726
- candidates.push({ name: targetName, slugs })
727
- }
728
- }
729
-
730
- let bestTarget: string | undefined
731
- if (candidates.length === 1) {
732
- bestTarget = candidates[0]!.name
733
- } else if (candidates.length > 1) {
734
- // Multiple matches — disambiguate using all field values
735
- const allValues = def.entries?.flatMap(e => {
736
- const v = e.data?.[field.name]
737
- return typeof v === 'string' ? [v] : []
738
- }) ?? stringExamples
739
- let bestOverlap = 0
740
- for (const c of candidates) {
741
- const overlap = allValues.filter(v => c.slugs.has(v)).length
742
- if (overlap > bestOverlap) {
743
- bestOverlap = overlap
744
- bestTarget = c.name
745
- }
746
- }
747
- }
748
- if (bestTarget) {
749
- field.type = 'reference'
750
- field.collection = bestTarget
751
- field.options = undefined
752
- }
753
- }
754
-
755
- if (field.type === 'array' && field.itemType === 'text' && field.options) {
756
- let bestTarget: string | undefined
757
- let bestOverlap = 0
758
- for (const [targetName, slugs] of collectionSlugs) {
759
- if (targetName === collectionName) continue
760
- const matchCount = field.options.filter(v => slugs.has(v)).length
761
- if (matchCount > 0 && matchCount >= field.options.length * 0.5) {
762
- if (matchCount > bestOverlap) {
763
- bestOverlap = matchCount
764
- bestTarget = targetName
765
- }
766
- }
767
- }
768
- if (bestTarget) {
769
- field.type = 'array'
770
- field.itemType = 'reference'
771
- field.collection = bestTarget
772
- field.options = undefined
773
- }
774
- }
775
- }
776
- }
777
- }
778
-
779
- /**
780
- * Tag fields with semantic roles so the editor UI can position them without
781
- * matching on Astro-specific field names. Detection lives here — the layer
782
- * that already knows it's parsing Astro content collections.
783
- */
784
- function assignSemanticRoles(collections: Record<string, CollectionDefinition>): void {
785
- for (const def of Object.values(collections)) {
786
- let toggle: FieldDefinition | undefined
787
- let dateByName: FieldDefinition | undefined
788
- let dateByType: FieldDefinition | undefined
789
- for (const field of def.fields) {
790
- if (field.hidden || field.role) continue
791
- const normalized = normalizeFieldName(field.name)
792
- if (!toggle && field.type === 'boolean' && PUBLISH_TOGGLE_NAMES.has(normalized)) {
793
- toggle = field
794
- } else if (!dateByName && PUBLISH_DATE_NAMES.has(normalized)) {
795
- dateByName = field
796
- } else if (!dateByType && (field.type === 'date' || field.type === 'datetime')) {
797
- dateByType = field
798
- }
799
- }
800
- if (toggle) toggle.role = 'publish-toggle'
801
- const date = dateByName ?? dateByType
802
- if (date) date.role = 'publish-date'
803
- }
804
- }
805
-
806
- /** Suffixes that indicate a field is a derived href/url/slug companion */
807
- const HREF_SUFFIXES = ['href', 'url', 'link', 'slug', 'path'] as const
808
-
809
- /**
810
- * Detect fields like `categoryHref` that are derived from a source field (`category`).
811
- * When every value is a slugified href of the source, mark it hidden with derivedFrom.
812
- */
813
- function detectDerivedHrefFields(collections: Record<string, CollectionDefinition>): void {
814
- for (const def of Object.values(collections)) {
815
- const fieldsByName = new Map(def.fields.map(f => [f.name, f]))
816
-
817
- for (const field of def.fields) {
818
- if (field.hidden || field.derivedFrom) continue
819
-
820
- const lowerName = field.name.toLowerCase()
821
- for (const suffix of HREF_SUFFIXES) {
822
- if (!lowerName.endsWith(suffix)) continue
823
- const baseName = field.name.slice(0, -suffix.length)
824
- if (!baseName) continue
825
-
826
- // Case-insensitive lookup: exact match first, then scan by lowercased name
827
- let sourceField = fieldsByName.get(baseName)
828
- if (!sourceField) {
829
- const lowerBase = baseName.toLowerCase()
830
- for (const f of fieldsByName.values()) {
831
- if (f.name.toLowerCase() === lowerBase) {
832
- sourceField = f
833
- break
834
- }
835
- }
836
- }
837
- if (!sourceField || !sourceField.examples || !field.examples) continue
838
-
839
- const sourceExamples = sourceField.examples.filter((v): v is string => typeof v === 'string')
840
- const derivedExamples = field.examples.filter((v): v is string => typeof v === 'string')
841
- if (sourceExamples.length === 0 || derivedExamples.length === 0) continue
842
-
843
- // Order-independent: check that every derived value matches some source value's href
844
- const expectedHrefs = new Set(sourceExamples.map(slugifyHref))
845
- const allMatch = derivedExamples.every(v => expectedHrefs.has(v))
846
- if (allMatch) {
847
- field.hidden = true
848
- field.derivedFrom = sourceField.name
849
- break
850
- }
851
- }
852
- }
853
- }
854
- }
855
-
856
- /**
857
- * Scan a data collection (JSON/YAML files) and infer its schema
858
- */
859
- async function scanDataCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
860
- try {
861
- const dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
862
-
863
- const sources: Array<{ slug: string; relPath: string }> = []
864
- const takenSlugs = new Set<string>()
865
-
866
- for (const entry of dirEntries) {
867
- if (!entry.isFile()) continue
868
- if (!entry.name.endsWith('.json') && !entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml')) continue
869
- const slug = entry.name.replace(/\.(json|ya?ml)$/, '')
870
- sources.push({ slug, relPath: entry.name })
871
- takenSlugs.add(slug)
872
- }
873
-
874
- // Hugo-style layout: <slug>/index.{json,yaml,yml}. Flat files win on slug conflict.
875
- const subdirs = dirEntries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
876
- const indexLookups = await Promise.all(subdirs.map(async dir => {
877
- if (takenSlugs.has(dir.name)) return null
878
- for (const indexExt of ['json', 'yaml', 'yml'] as const) {
879
- const relPath = path.join(dir.name, `index.${indexExt}`)
880
- try {
881
- await fs.access(path.join(collectionPath, relPath))
882
- return { slug: dir.name, relPath }
883
- } catch {
884
- // try next extension
885
- }
886
- }
887
- return null
888
- }))
889
- for (const entry of indexLookups) {
890
- if (entry) sources.push(entry)
891
- }
892
-
893
- if (sources.length === 0) return null
894
-
895
- const fieldMap = new Map<string, FieldObservation>()
896
- const entryInfos: CollectionEntryInfo[] = []
897
- const ext = sources.some(s => s.relPath.endsWith('.json'))
898
- ? 'json' as const
899
- : sources.some(s => s.relPath.endsWith('.yaml'))
900
- ? 'yaml' as const
901
- : 'yml' as const
902
-
903
- const fileContents = await Promise.all(
904
- sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8').catch(() => null)),
905
- )
906
-
907
- for (let i = 0; i < sources.length; i++) {
908
- const source = sources[i]!
909
- const raw = fileContents[i]!
910
- if (raw === null) continue
911
- let data: Record<string, unknown> | null = null
912
- try {
913
- data = source.relPath.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) as Record<string, unknown>
914
- } catch {
915
- continue
916
- }
917
- if (!data || typeof data !== 'object') continue
918
-
919
- const title = typeof data.name === 'string' ? data.name : typeof data.title === 'string' ? data.title : undefined
920
- entryInfos.push({
921
- slug: source.slug,
922
- title,
923
- sourcePath: path.join(contentDir, collectionName, source.relPath),
924
- data,
925
- })
926
-
927
- collectFieldObservations(fieldMap, data, sources.length)
928
- }
929
-
930
- return assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
931
- type: 'data',
932
- fileExtension: ext,
933
- })
934
- } catch {
935
- return null
936
- }
937
- }
938
-
939
- /**
940
- * Scan all collections in the content directory
941
- */
942
- export async function scanCollections(contentDir: string = 'src/content'): Promise<Record<string, CollectionDefinition>> {
943
- const projectRoot = getProjectRoot()
944
- const fullContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir)
945
-
946
- const collections: Record<string, CollectionDefinition> = {}
947
-
948
- try {
949
- const entries = await fs.readdir(fullContentDir, { withFileTypes: true })
950
-
951
- const scanPromises = entries
952
- .filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
953
- .map(async entry => {
954
- const collectionPath = path.join(fullContentDir, entry.name)
955
- const definition = await scanCollection(collectionPath, entry.name, contentDir)
956
- ?? await scanDataCollection(collectionPath, entry.name, contentDir)
957
- if (definition) {
958
- collections[entry.name] = definition
959
- }
960
- })
961
-
962
- await Promise.all(scanPromises)
963
- } catch {
964
- // Content directory doesn't exist or isn't readable
965
- }
966
-
967
- // Post-scan: apply schema-driven field config, detect references, derived fields, and ordering
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
-
983
- applyParsedConfig(collections, parsed)
984
- detectReferenceFields(collections, parsed)
985
- detectDerivedHrefFields(collections)
986
- assignSemanticRoles(collections)
987
- applyCollectionOrderBy(collections, parsed)
988
-
989
- return collections
990
- }