@nuasite/cms 0.18.1 → 0.19.1
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 +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- 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.
|
|
17
|
+
"version": "0.19.1",
|
|
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.
|
|
36
|
-
"@milkdown/ctx": "^7.
|
|
37
|
-
"@milkdown/plugin-listener": "^7.
|
|
38
|
-
"@milkdown/preset-commonmark": "^7.
|
|
39
|
-
"@milkdown/preset-gfm": "^7.
|
|
40
|
-
"@milkdown/prose": "^7.
|
|
41
|
-
"@milkdown/
|
|
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.
|
|
47
|
-
"preact": "^10.29.
|
|
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.
|
|
56
|
-
"prosemirror-view": "^1.41.
|
|
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": "
|
|
62
|
+
"astro": "6.1.3",
|
|
61
63
|
"typescript": "^6.0.2",
|
|
62
|
-
"vite": "^
|
|
64
|
+
"vite": "^7.0.0",
|
|
63
65
|
"@aws-sdk/client-s3": "^3.0.0"
|
|
64
66
|
},
|
|
65
67
|
"peerDependenciesMeta": {
|
package/src/build-processor.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
name:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
}
|