@nuasite/cms 0.18.1 → 0.19.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.
Files changed (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. package/src/utils.ts +40 -4
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.18.1",
17
+ "version": "0.19.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -32,19 +32,21 @@
32
32
  "yaml": "^2.8.3"
33
33
  },
34
34
  "devDependencies": {
35
- "@milkdown/core": "^7.19.2",
36
- "@milkdown/ctx": "^7.19.2",
37
- "@milkdown/plugin-listener": "^7.19.2",
38
- "@milkdown/preset-commonmark": "^7.19.2",
39
- "@milkdown/preset-gfm": "^7.19.2",
40
- "@milkdown/prose": "^7.19.2",
41
- "@milkdown/utils": "^7.19.2",
35
+ "@milkdown/core": "^7.20.0",
36
+ "@milkdown/ctx": "^7.20.0",
37
+ "@milkdown/plugin-listener": "^7.20.0",
38
+ "@milkdown/preset-commonmark": "^7.20.0",
39
+ "@milkdown/preset-gfm": "^7.20.0",
40
+ "@milkdown/prose": "^7.20.0",
41
+ "@milkdown/transformer": "^7.20.0",
42
+ "@milkdown/utils": "^7.20.0",
43
+ "remark-mdx": "^3.1.0",
42
44
  "@preact/signals": "^2.9.0",
43
45
  "@tailwindcss/vite": "^4.2.2",
44
46
  "@types/bun": "1.3.11",
45
47
  "clsx": "^2.1.1",
46
- "marked": "^17.0.5",
47
- "preact": "^10.29.0",
48
+ "marked": "^17.0.6",
49
+ "preact": "^10.29.1",
48
50
  "prosemirror-commands": "^1.7.1",
49
51
  "prosemirror-inputrules": "^1.5.1",
50
52
  "prosemirror-keymap": "^1.2.3",
@@ -52,14 +54,14 @@
52
54
  "prosemirror-safari-ime-span": "^1.0.2",
53
55
  "prosemirror-schema-list": "^1.5.1",
54
56
  "prosemirror-state": "^1.4.4",
55
- "prosemirror-transform": "^1.10.5",
56
- "prosemirror-view": "^1.41.7",
57
+ "prosemirror-transform": "^1.12.0",
58
+ "prosemirror-view": "^1.41.8",
57
59
  "tailwind-merge": "^3.5.0"
58
60
  },
59
61
  "peerDependencies": {
60
- "astro": "^6.1.1",
62
+ "astro": "6.1.3",
61
63
  "typescript": "^6.0.2",
62
- "vite": "^8.0.3",
64
+ "vite": "^7.0.0",
63
65
  "@aws-sdk/client-s3": "^3.0.0"
64
66
  },
65
67
  "peerDependenciesMeta": {
@@ -21,6 +21,7 @@ import {
21
21
  updateAttributeSources,
22
22
  updateColorClassSources,
23
23
  } from './source-finder'
24
+ import type { ComponentInstance } from './types'
24
25
  import type { CmsMarkerOptions, CollectionEntry } from './types'
25
26
 
26
27
  // Concurrency limit for parallel processing
@@ -215,7 +216,7 @@ async function parseComponentInvocations(
215
216
  async function detectEntrylessComponents(
216
217
  pagePath: string,
217
218
  root: ReturnType<typeof parse>,
218
- components: Record<string, import('./types').ComponentInstance>,
219
+ components: Record<string, ComponentInstance>,
219
220
  componentDirs: string[],
220
221
  relPath: string,
221
222
  idGenerator: () => string,
@@ -351,6 +352,8 @@ async function processFile(
351
352
  : undefined,
352
353
  // Pass SEO options
353
354
  seo: config.seo,
355
+ // Pass collection definitions for resolving frontmatter text on listing pages
356
+ collectionDefinitions: manifestWriter.getCollectionDefinitions(),
354
357
  },
355
358
  idGenerator,
356
359
  )
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
- import { parse as parseYaml } from 'yaml'
3
+ import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
4
4
  import { getProjectRoot } from './config'
5
+ import { slugifyHref } from './shared'
5
6
  import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
6
7
 
7
8
  /** Regex patterns for type inference */
@@ -15,6 +16,25 @@ const MAX_SELECT_OPTIONS = 10
15
16
  /** Minimum length for textarea detection */
16
17
  const TEXTAREA_MIN_LENGTH = 200
17
18
 
19
+ /** Field names that default to sidebar position */
20
+ const SIDEBAR_FIELD_NAMES = new Set([
21
+ 'title',
22
+ 'date',
23
+ 'pubdate',
24
+ 'publishdate',
25
+ 'draft',
26
+ 'image',
27
+ 'featuredimage',
28
+ 'cover',
29
+ 'coverimage',
30
+ 'thumbnail',
31
+ 'author',
32
+ ])
33
+
34
+ /** Directive pattern: # @position <value> or # @group <value> */
35
+ /** Matches `@position <value>` or `@group <value>` in YAML comment text (# already stripped by parser) */
36
+ const DIRECTIVE_PATTERN = /^\s*@(position|group)\s+(.+)$/
37
+
18
38
  /** Field names that should never be inferred as select (always free-text) */
19
39
  const FREE_TEXT_FIELD_NAMES = new Set([
20
40
  'title',
@@ -40,14 +60,80 @@ interface FieldObservation {
40
60
  totalEntries: number
41
61
  }
42
62
 
63
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/
64
+
65
+ function extractFrontmatterBlock(content: string): string | null {
66
+ const match = content.match(FRONTMATTER_PATTERN)
67
+ return match?.[1] ?? null
68
+ }
69
+
70
+ function parseFrontmatter(content: string): Record<string, unknown> | null {
71
+ const block = extractFrontmatterBlock(content)
72
+ if (!block) return null
73
+ return parseYaml(block) as Record<string, unknown> | null
74
+ }
75
+
43
76
  /**
44
- * Parse YAML frontmatter from markdown content
77
+ * Parse @position and @group comment directives from raw YAML frontmatter.
78
+ * Uses the YAML AST which preserves comments via `commentBefore` on nodes.
45
79
  */
46
- function parseFrontmatter(content: string): Record<string, unknown> | null {
47
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
48
- if (!match?.[1]) return null
80
+ function parseFieldDirectives(content: string): Record<string, { position?: 'sidebar' | 'header'; group?: string }> {
81
+ const block = extractFrontmatterBlock(content)
82
+ if (!block) return {}
83
+
84
+ const doc = parseDocument(block)
85
+ if (!isMap(doc.contents)) return {}
86
+
87
+ const result: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
88
+
89
+ for (const pair of doc.contents.items) {
90
+ if (!isPair(pair) || !isScalar(pair.key)) continue
91
+ const comment = (pair.key as any).commentBefore as string | undefined
92
+ if (!comment) continue
93
+
94
+ const directives: { position?: 'sidebar' | 'header'; group?: string } = {}
95
+ for (const line of comment.split('\n')) {
96
+ const match = line.trim().match(DIRECTIVE_PATTERN)
97
+ if (!match) continue
98
+ const [, dirKey, dirValue] = match
99
+ if (dirKey === 'position' && (dirValue === 'sidebar' || dirValue === 'header')) {
100
+ directives.position = dirValue
101
+ } else if (dirKey === 'group' && dirValue) {
102
+ directives.group = dirValue.trim()
103
+ }
104
+ }
105
+
106
+ if (directives.position || directives.group) {
107
+ result[String(pair.key.value)] = directives
108
+ }
109
+ }
110
+
111
+ return result
112
+ }
113
+
114
+ /**
115
+ * Assign default positions to fields based on field name heuristics,
116
+ * then overlay frontmatter comment directives.
117
+ */
118
+ function assignFieldMetadata(
119
+ fields: FieldDefinition[],
120
+ directives: Record<string, { position?: 'sidebar' | 'header'; group?: string }>,
121
+ ): void {
122
+ for (const field of fields) {
123
+ // Scanner defaults: well-known fields go to sidebar
124
+ if (SIDEBAR_FIELD_NAMES.has(field.name.toLowerCase()) || field.type === 'image' || field.type === 'boolean') {
125
+ field.position = 'sidebar'
126
+ } else {
127
+ field.position = 'header'
128
+ }
49
129
 
50
- return parseYaml(match[1]) as Record<string, unknown> | null
130
+ // Overlay frontmatter comment directives
131
+ const directive = directives[field.name]
132
+ if (directive) {
133
+ if (directive.position) field.position = directive.position
134
+ if (directive.group) field.group = directive.group
135
+ }
136
+ }
51
137
  }
52
138
 
53
139
  /**
@@ -167,6 +253,20 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
167
253
  field.options = uniqueItems.sort()
168
254
  }
169
255
  }
256
+
257
+ // Infer sub-field definitions for array-of-objects
258
+ if (itemType === 'object') {
259
+ const objectItems = allItems.filter(
260
+ (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v),
261
+ )
262
+ if (objectItems.length > 0) {
263
+ const subFieldMap = new Map<string, FieldObservation>()
264
+ for (const item of objectItems) {
265
+ collectFieldObservations(subFieldMap, item, objectItems.length)
266
+ }
267
+ field.fields = mergeFieldObservations(Array.from(subFieldMap.values()))
268
+ }
269
+ }
170
270
  }
171
271
  }
172
272
 
@@ -176,6 +276,51 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
176
276
  return fields
177
277
  }
178
278
 
279
+ function collectFieldObservations(
280
+ fieldMap: Map<string, FieldObservation>,
281
+ data: Record<string, unknown>,
282
+ totalEntries: number,
283
+ ): void {
284
+ for (const [key, value] of Object.entries(data)) {
285
+ let obs = fieldMap.get(key)
286
+ if (!obs) {
287
+ obs = { name: key, values: [], presentCount: 0, totalEntries }
288
+ fieldMap.set(key, obs)
289
+ }
290
+ obs.values.push(value)
291
+ obs.presentCount++
292
+ }
293
+ }
294
+
295
+ function buildCollectionDefinition(
296
+ collectionName: string,
297
+ contentDir: string,
298
+ fieldMap: Map<string, FieldObservation>,
299
+ entryInfos: CollectionEntryInfo[],
300
+ entryCount: number,
301
+ extra: Partial<CollectionDefinition>,
302
+ ): CollectionDefinition {
303
+ for (const obs of fieldMap.values()) {
304
+ obs.totalEntries = entryCount
305
+ }
306
+
307
+ entryInfos.sort((a, b) => (a.title ?? a.slug).localeCompare(b.title ?? b.slug))
308
+
309
+ const fields = mergeFieldObservations(Array.from(fieldMap.values()))
310
+ const label = collectionName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
311
+
312
+ return {
313
+ name: collectionName,
314
+ label,
315
+ path: path.join(contentDir, collectionName),
316
+ entryCount,
317
+ fields,
318
+ fileExtension: 'md',
319
+ entries: entryInfos,
320
+ ...extra,
321
+ }
322
+ }
323
+
179
324
  /**
180
325
  * Scan a single collection directory and infer its schema
181
326
  */
@@ -186,21 +331,30 @@ async function scanCollection(collectionPath: string, collectionName: string, co
186
331
 
187
332
  if (markdownFiles.length === 0) return null
188
333
 
189
- // Determine file extension (prefer md, use mdx if that's all we have)
190
334
  const hasMd = markdownFiles.some(f => f.name.endsWith('.md'))
191
335
  const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
192
336
 
193
- // Collect field observations and entry info across all files
194
337
  const fieldMap = new Map<string, FieldObservation>()
338
+ const allDirectives: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
195
339
  const entryInfos: CollectionEntryInfo[] = []
196
340
  let hasDraft = false
197
341
 
198
- for (const file of markdownFiles) {
199
- const filePath = path.join(collectionPath, file.name)
200
- const content = await fs.readFile(filePath, 'utf-8')
342
+ const fileContents = await Promise.all(
343
+ markdownFiles.map(file => fs.readFile(path.join(collectionPath, file.name), 'utf-8')),
344
+ )
345
+
346
+ for (let i = 0; i < markdownFiles.length; i++) {
347
+ const file = markdownFiles[i]!
348
+ const content = fileContents[i]!
201
349
  const frontmatter = parseFrontmatter(content)
202
350
 
203
- // Collect entry info
351
+ const directives = parseFieldDirectives(content)
352
+ for (const [key, value] of Object.entries(directives)) {
353
+ if (!allDirectives[key]) {
354
+ allDirectives[key] = value
355
+ }
356
+ }
357
+
204
358
  const slug = file.name.replace(/\.(md|mdx)$/, '')
205
359
  const entryInfo: CollectionEntryInfo = {
206
360
  slug,
@@ -213,61 +367,279 @@ async function scanCollection(collectionPath: string, collectionName: string, co
213
367
  if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
214
368
  entryInfo.draft = true
215
369
  }
370
+ entryInfo.data = frontmatter
216
371
  }
217
372
  entryInfos.push(entryInfo)
218
373
 
219
374
  if (!frontmatter) continue
220
375
 
221
- for (const [key, value] of Object.entries(frontmatter)) {
222
- if (key === 'draft' && typeof value === 'boolean') {
223
- hasDraft = true
376
+ if (frontmatter.draft === true) hasDraft = true
377
+ collectFieldObservations(fieldMap, frontmatter, markdownFiles.length)
378
+ }
379
+
380
+ const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, markdownFiles.length, {
381
+ supportsDraft: hasDraft,
382
+ fileExtension,
383
+ })
384
+ assignFieldMetadata(def.fields, allDirectives)
385
+ return def
386
+ } catch {
387
+ return null
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Parse the Astro content config file to extract explicit reference() declarations.
393
+ * Returns a map: collectionName → { fieldName → { target, isArray } }
394
+ */
395
+ async function parseContentConfigReferences(): Promise<Map<string, Map<string, { target: string; isArray: boolean }>>> {
396
+ const result = new Map<string, Map<string, { target: string; isArray: boolean }>>()
397
+ const projectRoot = getProjectRoot()
398
+
399
+ for (const configPath of ['src/content/config.ts', 'src/content.config.ts']) {
400
+ try {
401
+ const fullPath = path.join(projectRoot, configPath)
402
+ const content = await fs.readFile(fullPath, 'utf-8')
403
+
404
+ // Parse defineCollection blocks to extract schema bodies
405
+ const collectionBlocks = content.matchAll(
406
+ /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*z\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g,
407
+ )
408
+
409
+ // Map variable names to collection names from exports
410
+ const varToName = new Map<string, string>()
411
+ const exportMatch = content.match(/export\s+const\s+collections\s*=\s*\{([\s\S]*?)\}/)
412
+ if (exportMatch) {
413
+ const pairs = exportMatch[1]!.matchAll(/(\w+)\s*:\s*(\w+)/g)
414
+ for (const m of pairs) {
415
+ varToName.set(m[2]!, m[1]!)
224
416
  }
417
+ }
418
+
419
+ for (const block of collectionBlocks) {
420
+ const varName = block[1]
421
+ const schemaBody = block[2]!
422
+ const collectionName = varName ? varToName.get(varName) : undefined
423
+ if (!collectionName) continue
424
+
425
+ const fields = new Map<string, { target: string; isArray: boolean }>()
426
+ const fieldRefs = schemaBody.matchAll(/(\w+)\s*:\s*(z\.array\s*\(\s*)?reference\s*\(\s*['"](\w+)['"]\s*\)/g)
427
+ for (const m of fieldRefs) {
428
+ fields.set(m[1]!, { target: m[3]!, isArray: !!m[2] })
429
+ }
430
+
431
+ if (fields.size > 0) {
432
+ result.set(collectionName, fields)
433
+ }
434
+ }
435
+
436
+ if (result.size > 0) break // Found a config file with references
437
+ } catch {
438
+ // File doesn't exist, try next
439
+ }
440
+ }
441
+ return result
442
+ }
443
+
444
+ /**
445
+ * After all collections are scanned, detect reference fields.
446
+ * Prefers explicit reference() declarations from the content config file.
447
+ * Falls back to heuristic slug matching when no config is available.
448
+ */
449
+ async function detectReferenceFields(collections: Record<string, CollectionDefinition>): Promise<void> {
450
+ // Try parsing the content config first — this is the source of truth
451
+ const configRefs = await parseContentConfigReferences()
452
+ if (configRefs.size > 0) {
453
+ for (const [collectionName, fieldRefs] of configRefs) {
454
+ const def = collections[collectionName]
455
+ if (!def) continue
456
+ for (const [fieldName, ref] of fieldRefs) {
457
+ const field = def.fields.find(f => f.name === fieldName)
458
+ if (!field) continue
459
+ if (ref.isArray) {
460
+ field.type = 'array'
461
+ field.itemType = 'reference'
462
+ } else {
463
+ field.type = 'reference'
464
+ }
465
+ field.collection = ref.target
466
+ field.options = undefined
467
+ }
468
+ }
469
+ return
470
+ }
471
+
472
+ // Fallback: heuristic detection by matching field values against collection slugs
473
+ detectReferenceFieldsBySlugMatch(collections)
474
+ }
475
+
476
+ function detectReferenceFieldsBySlugMatch(collections: Record<string, CollectionDefinition>): void {
477
+ const collectionSlugs = new Map<string, Set<string>>()
478
+ for (const [name, def] of Object.entries(collections)) {
479
+ if (def.entries && def.entries.length > 0) {
480
+ collectionSlugs.set(name, new Set(def.entries.map(e => e.slug)))
481
+ }
482
+ }
225
483
 
226
- let obs = fieldMap.get(key)
227
- if (!obs) {
228
- obs = {
229
- name: key,
230
- values: [],
231
- presentCount: 0,
232
- totalEntries: markdownFiles.length,
484
+ for (const [collectionName, def] of Object.entries(collections)) {
485
+ for (const field of def.fields) {
486
+ if ((field.type === 'text' || field.type === 'select') && field.examples) {
487
+ const stringExamples = field.examples.filter((v): v is string => typeof v === 'string')
488
+ if (stringExamples.length === 0) continue
489
+
490
+ // Find all candidate collections where all examples match slugs
491
+ const candidates: Array<{ name: string; slugs: Set<string> }> = []
492
+ for (const [targetName, slugs] of collectionSlugs) {
493
+ if (targetName === collectionName) continue
494
+ const matchCount = stringExamples.filter(v => slugs.has(v)).length
495
+ if (matchCount > 0 && matchCount === stringExamples.length) {
496
+ candidates.push({ name: targetName, slugs })
233
497
  }
234
- fieldMap.set(key, obs)
235
498
  }
236
499
 
237
- obs.values.push(value)
238
- obs.presentCount++
500
+ let bestTarget: string | undefined
501
+ if (candidates.length === 1) {
502
+ bestTarget = candidates[0]!.name
503
+ } else if (candidates.length > 1) {
504
+ // Multiple matches — disambiguate using all field values
505
+ const allValues = def.entries?.flatMap(e => {
506
+ const v = e.data?.[field.name]
507
+ return typeof v === 'string' ? [v] : []
508
+ }) ?? stringExamples
509
+ let bestOverlap = 0
510
+ for (const c of candidates) {
511
+ const overlap = allValues.filter(v => c.slugs.has(v)).length
512
+ if (overlap > bestOverlap) {
513
+ bestOverlap = overlap
514
+ bestTarget = c.name
515
+ }
516
+ }
517
+ }
518
+ if (bestTarget) {
519
+ field.type = 'reference'
520
+ field.collection = bestTarget
521
+ field.options = undefined
522
+ }
523
+ }
524
+
525
+ if (field.type === 'array' && field.itemType === 'text' && field.options) {
526
+ let bestTarget: string | undefined
527
+ let bestOverlap = 0
528
+ for (const [targetName, slugs] of collectionSlugs) {
529
+ if (targetName === collectionName) continue
530
+ const matchCount = field.options.filter(v => slugs.has(v)).length
531
+ if (matchCount > 0 && matchCount >= field.options.length * 0.5) {
532
+ if (matchCount > bestOverlap) {
533
+ bestOverlap = matchCount
534
+ bestTarget = targetName
535
+ }
536
+ }
537
+ }
538
+ if (bestTarget) {
539
+ field.type = 'array'
540
+ field.itemType = 'reference'
541
+ field.collection = bestTarget
542
+ field.options = undefined
543
+ }
239
544
  }
240
545
  }
546
+ }
547
+ }
241
548
 
242
- // Sort entries alphabetically by title (fallback to slug)
243
- entryInfos.sort((a, b) => {
244
- const aLabel = a.title ?? a.slug
245
- const bLabel = b.title ?? b.slug
246
- return aLabel.localeCompare(bLabel)
247
- })
549
+ /** Suffixes that indicate a field is a derived href/url/slug companion */
550
+ const HREF_SUFFIXES = ['href', 'url', 'link', 'slug', 'path'] as const
248
551
 
249
- // Update totalEntries for all observations
250
- for (const obs of fieldMap.values()) {
251
- obs.totalEntries = markdownFiles.length
552
+ /**
553
+ * Detect fields like `categoryHref` that are derived from a source field (`category`).
554
+ * When every value is a slugified href of the source, mark it hidden with derivedFrom.
555
+ */
556
+ function detectDerivedHrefFields(collections: Record<string, CollectionDefinition>): void {
557
+ for (const def of Object.values(collections)) {
558
+ const fieldsByName = new Map(def.fields.map(f => [f.name, f]))
559
+
560
+ for (const field of def.fields) {
561
+ if (field.hidden || field.derivedFrom) continue
562
+
563
+ const lowerName = field.name.toLowerCase()
564
+ for (const suffix of HREF_SUFFIXES) {
565
+ if (!lowerName.endsWith(suffix)) continue
566
+ const baseName = field.name.slice(0, -suffix.length)
567
+ if (!baseName) continue
568
+
569
+ // Case-insensitive lookup: exact match first, then scan by lowercased name
570
+ let sourceField = fieldsByName.get(baseName)
571
+ if (!sourceField) {
572
+ const lowerBase = baseName.toLowerCase()
573
+ for (const f of fieldsByName.values()) {
574
+ if (f.name.toLowerCase() === lowerBase) {
575
+ sourceField = f
576
+ break
577
+ }
578
+ }
579
+ }
580
+ if (!sourceField || !sourceField.examples || !field.examples) continue
581
+
582
+ const sourceExamples = sourceField.examples.filter((v): v is string => typeof v === 'string')
583
+ const derivedExamples = field.examples.filter((v): v is string => typeof v === 'string')
584
+ if (sourceExamples.length === 0 || derivedExamples.length === 0) continue
585
+
586
+ // Order-independent: check that every derived value matches some source value's href
587
+ const expectedHrefs = new Set(sourceExamples.map(slugifyHref))
588
+ const allMatch = derivedExamples.every(v => expectedHrefs.has(v))
589
+ if (allMatch) {
590
+ field.hidden = true
591
+ field.derivedFrom = sourceField.name
592
+ break
593
+ }
594
+ }
252
595
  }
596
+ }
597
+ }
253
598
 
254
- const fields = mergeFieldObservations(Array.from(fieldMap.values()))
599
+ /**
600
+ * Scan a data collection (JSON/YAML files) and infer its schema
601
+ */
602
+ async function scanDataCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
603
+ try {
604
+ const entries = await fs.readdir(collectionPath, { withFileTypes: true })
605
+ const dataFiles = entries.filter(e => e.isFile() && (e.name.endsWith('.json') || e.name.endsWith('.yaml') || e.name.endsWith('.yml')))
606
+ if (dataFiles.length === 0) return null
255
607
 
256
- // Generate a human-readable label
257
- const label = collectionName
258
- .replace(/[-_]/g, ' ')
259
- .replace(/\b\w/g, c => c.toUpperCase())
608
+ const fieldMap = new Map<string, FieldObservation>()
609
+ const entryInfos: CollectionEntryInfo[] = []
610
+ const ext = dataFiles.some(file => file.name.endsWith('.json'))
611
+ ? 'json' as const
612
+ : dataFiles.some(file => file.name.endsWith('.yaml'))
613
+ ? 'yaml' as const
614
+ : 'yml' as const
615
+
616
+ const fileContents = await Promise.all(
617
+ dataFiles.map(file => fs.readFile(path.join(collectionPath, file.name), 'utf-8').catch(() => null)),
618
+ )
619
+
620
+ for (let i = 0; i < dataFiles.length; i++) {
621
+ const file = dataFiles[i]!
622
+ const raw = fileContents[i]!
623
+ if (raw === null) continue
624
+ let data: Record<string, unknown> | null = null
625
+ try {
626
+ data = file.name.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) as Record<string, unknown>
627
+ } catch {
628
+ continue
629
+ }
630
+ if (!data || typeof data !== 'object') continue
260
631
 
261
- return {
262
- name: collectionName,
263
- label,
264
- path: path.join(contentDir, collectionName),
265
- entryCount: markdownFiles.length,
266
- fields,
267
- supportsDraft: hasDraft,
268
- fileExtension,
269
- entries: entryInfos,
632
+ const slug = file.name.replace(/\.(json|ya?ml)$/, '')
633
+ const title = typeof data.name === 'string' ? data.name : typeof data.title === 'string' ? data.title : undefined
634
+ entryInfos.push({ slug, title, sourcePath: path.join(contentDir, collectionName, file.name), data })
635
+
636
+ collectFieldObservations(fieldMap, data, dataFiles.length)
270
637
  }
638
+
639
+ return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, dataFiles.length, {
640
+ type: 'data',
641
+ fileExtension: ext,
642
+ })
271
643
  } catch {
272
644
  return null
273
645
  }
@@ -290,6 +662,7 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
290
662
  .map(async entry => {
291
663
  const collectionPath = path.join(fullContentDir, entry.name)
292
664
  const definition = await scanCollection(collectionPath, entry.name, contentDir)
665
+ ?? await scanDataCollection(collectionPath, entry.name, contentDir)
293
666
  if (definition) {
294
667
  collections[entry.name] = definition
295
668
  }
@@ -300,5 +673,9 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
300
673
  // Content directory doesn't exist or isn't readable
301
674
  }
302
675
 
676
+ // Post-scan: detect cross-collection references and derived fields
677
+ await detectReferenceFields(collections)
678
+ detectDerivedHrefFields(collections)
679
+
303
680
  return collections
304
681
  }