@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.
- 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 +76 -2
- 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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { isMap, isPair, isScalar, LineCounter, parseDocument } from 'yaml'
|
|
3
|
+
import { isMap, isPair, isScalar, isSeq, LineCounter, parseDocument } from 'yaml'
|
|
4
4
|
|
|
5
5
|
import { getProjectRoot } from '../config'
|
|
6
|
+
import type { CollectionDefinition } from '../types'
|
|
6
7
|
import { getMarkdownFileCache } from './cache'
|
|
7
8
|
import { normalizeText } from './snippet-utils'
|
|
8
9
|
import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
|
|
@@ -194,63 +195,240 @@ export async function findMarkdownSourceLocation(
|
|
|
194
195
|
const { lines } = cached
|
|
195
196
|
const normalizedSearch = normalizeText(textContent)
|
|
196
197
|
|
|
197
|
-
//
|
|
198
|
+
// Find frontmatter boundaries
|
|
199
|
+
let frontmatterStart = -1
|
|
198
200
|
let frontmatterEnd = -1
|
|
199
|
-
let inFrontmatter = false
|
|
200
|
-
|
|
201
201
|
for (let i = 0; i < lines.length; i++) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
inFrontmatter = true
|
|
202
|
+
if (lines[i]?.trim() === '---') {
|
|
203
|
+
if (frontmatterStart === -1) {
|
|
204
|
+
frontmatterStart = i
|
|
206
205
|
} else {
|
|
207
206
|
frontmatterEnd = i
|
|
208
207
|
break
|
|
209
208
|
}
|
|
210
209
|
}
|
|
211
210
|
}
|
|
211
|
+
if (frontmatterEnd <= 0) return undefined
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// Extract value from YAML key: value (keys can contain hyphens)
|
|
220
|
-
const match = line.match(/^\s*([\w-]+):\s*(.+)$/)
|
|
221
|
-
if (match) {
|
|
222
|
-
const key = match[1]
|
|
223
|
-
let value = match[2]?.trim() || ''
|
|
224
|
-
|
|
225
|
-
// Handle quoted strings
|
|
226
|
-
if (
|
|
227
|
-
(value.startsWith('"') && value.endsWith('"'))
|
|
228
|
-
|| (value.startsWith("'") && value.endsWith("'"))
|
|
229
|
-
) {
|
|
230
|
-
value = value.slice(1, -1)
|
|
231
|
-
}
|
|
213
|
+
const yamlStr = lines.slice(frontmatterStart + 1, frontmatterEnd).join('\n')
|
|
214
|
+
const lineOffset = frontmatterStart + 1
|
|
215
|
+
return findScalarInYamlAst(yamlStr, lineOffset, normalizedSearch, lines, collectionInfo)
|
|
216
|
+
} catch {
|
|
217
|
+
// Error reading file
|
|
218
|
+
}
|
|
232
219
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
220
|
+
return undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Search all collection entries for a text value across all formats
|
|
225
|
+
* (markdown frontmatter, JSON, YAML data files).
|
|
226
|
+
*/
|
|
227
|
+
export async function findTextInAnyCollectionFrontmatter(
|
|
228
|
+
textContent: string,
|
|
229
|
+
collections: Record<string, CollectionDefinition>,
|
|
230
|
+
): Promise<SourceLocation | undefined> {
|
|
231
|
+
const normalizedSearch = normalizeText(textContent)
|
|
232
|
+
|
|
233
|
+
for (const def of Object.values(collections)) {
|
|
234
|
+
if (!def.entries || def.entries.length === 0) continue
|
|
235
|
+
|
|
236
|
+
for (const entry of def.entries) {
|
|
237
|
+
const info: CollectionInfo = { name: def.name, slug: entry.slug, file: entry.sourcePath }
|
|
238
|
+
|
|
239
|
+
if (def.type === 'data') {
|
|
240
|
+
const result = await findTextInDataFile(normalizedSearch, info)
|
|
241
|
+
if (result) return result
|
|
242
|
+
} else {
|
|
243
|
+
const result = await findMarkdownSourceLocation(textContent, info)
|
|
244
|
+
if (result) return result
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
|
+
}
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Search a data file (JSON, YAML, YML) for a scalar value using AST parsing.
|
|
253
|
+
* JSON is valid YAML, so parseDocument handles all formats uniformly.
|
|
254
|
+
*/
|
|
255
|
+
async function findTextInDataFile(
|
|
256
|
+
normalizedSearch: string,
|
|
257
|
+
collectionInfo: CollectionInfo,
|
|
258
|
+
): Promise<SourceLocation | undefined> {
|
|
259
|
+
try {
|
|
260
|
+
const filePath = path.join(getProjectRoot(), collectionInfo.file)
|
|
261
|
+
const cached = await getCachedMarkdownFile(filePath)
|
|
262
|
+
if (!cached) return undefined
|
|
247
263
|
|
|
248
|
-
|
|
249
|
-
// Use parseMarkdownContent to get the full body as one entry
|
|
264
|
+
return findScalarInYamlAst(cached.content, 0, normalizedSearch, cached.lines, collectionInfo)
|
|
250
265
|
} catch {
|
|
251
266
|
// Error reading file
|
|
252
267
|
}
|
|
268
|
+
return undefined
|
|
269
|
+
}
|
|
253
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Walk a YAML AST to find a scalar value matching the search text.
|
|
273
|
+
* Handles nested maps and sequences.
|
|
274
|
+
*/
|
|
275
|
+
function findScalarInYamlAst(
|
|
276
|
+
yamlStr: string,
|
|
277
|
+
lineOffset: number,
|
|
278
|
+
normalizedSearch: string,
|
|
279
|
+
fileLines: string[],
|
|
280
|
+
collectionInfo: CollectionInfo,
|
|
281
|
+
): SourceLocation | undefined {
|
|
282
|
+
const lineCounter = new LineCounter()
|
|
283
|
+
const doc = parseDocument(yamlStr, { lineCounter })
|
|
284
|
+
|
|
285
|
+
const found = walkYamlNode(doc.contents, normalizedSearch, lineCounter)
|
|
286
|
+
if (!found) return undefined
|
|
287
|
+
|
|
288
|
+
const fileStartLine = found.startLine + lineOffset
|
|
289
|
+
const fileEndLine = found.endLine + lineOffset
|
|
290
|
+
|
|
291
|
+
// Build snippet spanning all lines of the key-value pair (handles multi-line YAML values)
|
|
292
|
+
const snippet = fileLines.slice(fileStartLine - 1, fileEndLine).join('\n')
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
file: collectionInfo.file,
|
|
296
|
+
line: fileStartLine,
|
|
297
|
+
snippet,
|
|
298
|
+
type: 'collection',
|
|
299
|
+
variableName: found.key,
|
|
300
|
+
collectionName: collectionInfo.name,
|
|
301
|
+
collectionSlug: collectionInfo.slug,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Recursively walk a YAML node to find a scalar matching the search text */
|
|
306
|
+
function walkYamlNode(
|
|
307
|
+
node: unknown,
|
|
308
|
+
normalizedSearch: string,
|
|
309
|
+
lineCounter: LineCounter,
|
|
310
|
+
): { key: string | undefined; startLine: number; endLine: number } | undefined {
|
|
311
|
+
if (isMap(node)) {
|
|
312
|
+
for (const pair of node.items) {
|
|
313
|
+
if (!isPair(pair) || !isScalar(pair.key)) continue
|
|
314
|
+
const key = String(pair.key.value)
|
|
315
|
+
|
|
316
|
+
if (isScalar(pair.value)) {
|
|
317
|
+
if (normalizeText(String(pair.value.value)) === normalizedSearch) {
|
|
318
|
+
const keyRange = (pair.key as any).range as [number, number, number] | undefined
|
|
319
|
+
const valRange = (pair.value as any).range as [number, number, number] | undefined
|
|
320
|
+
const startLine = keyRange ? lineCounter.linePos(keyRange[0]).line : 1
|
|
321
|
+
const endLine = valRange ? lineCounter.linePos(valRange[1]).line : startLine
|
|
322
|
+
return { key, startLine, endLine }
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
// Recurse into nested maps/sequences
|
|
326
|
+
const nested = walkYamlNode(pair.value, normalizedSearch, lineCounter)
|
|
327
|
+
if (nested) return nested
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} else if (isSeq(node)) {
|
|
331
|
+
for (const item of node.items) {
|
|
332
|
+
if (isScalar(item)) {
|
|
333
|
+
if (normalizeText(String(item.value)) === normalizedSearch) {
|
|
334
|
+
const range = (item as any).range as [number, number, number] | undefined
|
|
335
|
+
const startLine = range ? lineCounter.linePos(range[0]).line : 1
|
|
336
|
+
const endLine = range ? lineCounter.linePos(range[1]).line : startLine
|
|
337
|
+
return { key: undefined, startLine, endLine }
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
const nested = walkYamlNode(item, normalizedSearch, lineCounter)
|
|
341
|
+
if (nested) return nested
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return undefined
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Find an image field by name in a specific collection entry's data file.
|
|
350
|
+
* Used when the rendered image URL has been transformed (e.g., Astro hashed filenames)
|
|
351
|
+
* and can't be matched by value, but we know the field name from the expression.
|
|
352
|
+
*/
|
|
353
|
+
export async function findFieldInCollectionEntry(
|
|
354
|
+
fieldName: string,
|
|
355
|
+
collectionName: string,
|
|
356
|
+
collectionSlug: string,
|
|
357
|
+
collectionDefinitions: Record<string, CollectionDefinition>,
|
|
358
|
+
): Promise<SourceLocation | undefined> {
|
|
359
|
+
const def = collectionDefinitions[collectionName]
|
|
360
|
+
if (!def?.entries) return undefined
|
|
361
|
+
|
|
362
|
+
const entry = def.entries.find((e) => e.slug === collectionSlug)
|
|
363
|
+
if (!entry) return undefined
|
|
364
|
+
|
|
365
|
+
const info: CollectionInfo = { name: collectionName, slug: collectionSlug, file: entry.sourcePath }
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const filePath = path.join(getProjectRoot(), entry.sourcePath)
|
|
369
|
+
const cached = await getCachedMarkdownFile(filePath)
|
|
370
|
+
if (!cached) return undefined
|
|
371
|
+
|
|
372
|
+
if (def.type === 'data') {
|
|
373
|
+
return findFieldByNameInYaml(cached.content, 0, fieldName, cached.lines, info)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// For markdown, search inside frontmatter only
|
|
377
|
+
const { lines } = cached
|
|
378
|
+
let fmStart = -1
|
|
379
|
+
let fmEnd = -1
|
|
380
|
+
for (let i = 0; i < lines.length; i++) {
|
|
381
|
+
if (lines[i]?.trim() === '---') {
|
|
382
|
+
if (fmStart === -1) fmStart = i
|
|
383
|
+
else {
|
|
384
|
+
fmEnd = i
|
|
385
|
+
break
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (fmEnd <= 0) return undefined
|
|
390
|
+
const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
|
|
391
|
+
return findFieldByNameInYaml(yamlStr, fmStart + 1, fieldName, lines, info)
|
|
392
|
+
} catch {
|
|
393
|
+
return undefined
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Walk a YAML AST to find a field by key name (regardless of its value).
|
|
399
|
+
*/
|
|
400
|
+
function findFieldByNameInYaml(
|
|
401
|
+
yamlStr: string,
|
|
402
|
+
lineOffset: number,
|
|
403
|
+
fieldName: string,
|
|
404
|
+
fileLines: string[],
|
|
405
|
+
collectionInfo: CollectionInfo,
|
|
406
|
+
): SourceLocation | undefined {
|
|
407
|
+
const lineCounter = new LineCounter()
|
|
408
|
+
const doc = parseDocument(yamlStr, { lineCounter })
|
|
409
|
+
if (!isMap(doc.contents)) return undefined
|
|
410
|
+
|
|
411
|
+
for (const pair of doc.contents.items) {
|
|
412
|
+
if (!isPair(pair) || !isScalar(pair.key)) continue
|
|
413
|
+
if (String(pair.key.value) !== fieldName) continue
|
|
414
|
+
if (!isScalar(pair.value)) continue
|
|
415
|
+
|
|
416
|
+
const keyRange = (pair.key as any).range as [number, number, number] | undefined
|
|
417
|
+
const valRange = (pair.value as any).range as [number, number, number] | undefined
|
|
418
|
+
const startLine = (keyRange ? lineCounter.linePos(keyRange[0]).line : 1) + lineOffset
|
|
419
|
+
const endLine = (valRange ? lineCounter.linePos(valRange[1]).line : startLine - lineOffset) + lineOffset
|
|
420
|
+
|
|
421
|
+
const snippet = fileLines.slice(startLine - 1, endLine).join('\n')
|
|
422
|
+
return {
|
|
423
|
+
file: collectionInfo.file,
|
|
424
|
+
line: startLine,
|
|
425
|
+
snippet,
|
|
426
|
+
type: 'collection',
|
|
427
|
+
variableName: fieldName,
|
|
428
|
+
collectionName: collectionInfo.name,
|
|
429
|
+
collectionSlug: collectionInfo.slug,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
254
432
|
return undefined
|
|
255
433
|
}
|
|
256
434
|
|
|
@@ -23,7 +23,13 @@ export { findAttributeSourceLocation } from './cross-file-tracker'
|
|
|
23
23
|
export { findImageSourceLocation } from './image-finder'
|
|
24
24
|
|
|
25
25
|
// Collection/markdown finding
|
|
26
|
-
export {
|
|
26
|
+
export {
|
|
27
|
+
findCollectionSource,
|
|
28
|
+
findFieldInCollectionEntry,
|
|
29
|
+
findMarkdownSourceLocation,
|
|
30
|
+
findTextInAnyCollectionFrontmatter,
|
|
31
|
+
parseMarkdownContent,
|
|
32
|
+
} from './collection-finder'
|
|
27
33
|
|
|
28
34
|
// Snippet utilities (used by html-processor)
|
|
29
35
|
export {
|
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
setSearchIndexInitialized,
|
|
17
17
|
} from './cache'
|
|
18
18
|
import { extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
|
|
19
|
-
import type { CachedParsedFile, SourceLocation } from './types'
|
|
19
|
+
import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
|
|
20
|
+
|
|
21
|
+
/** Collection data files live under this path — used to prefer them over templates */
|
|
22
|
+
const CONTENT_DIR_PREFIX = 'src/content/'
|
|
23
|
+
|
|
24
|
+
function isCollectionFile(file: string): boolean {
|
|
25
|
+
return file.includes(CONTENT_DIR_PREFIX)
|
|
26
|
+
}
|
|
20
27
|
|
|
21
28
|
// ============================================================================
|
|
22
29
|
// File Collection
|
|
@@ -56,13 +63,26 @@ export async function collectAstroFiles(dir: string): Promise<string[]> {
|
|
|
56
63
|
// Index Initialization
|
|
57
64
|
// ============================================================================
|
|
58
65
|
|
|
66
|
+
/** Shared promise so concurrent callers wait for the same initialization */
|
|
67
|
+
let initPromise: Promise<void> | null = null
|
|
68
|
+
|
|
59
69
|
/**
|
|
60
70
|
* Initialize search index by pre-scanning all source files.
|
|
61
71
|
* This is much faster than searching per-entry.
|
|
72
|
+
* Safe to call concurrently — all callers share the same initialization.
|
|
62
73
|
*/
|
|
63
74
|
export async function initializeSearchIndex(): Promise<void> {
|
|
64
75
|
if (isSearchIndexInitialized()) return
|
|
76
|
+
if (initPromise) return initPromise
|
|
77
|
+
initPromise = doInitializeSearchIndex()
|
|
78
|
+
try {
|
|
79
|
+
await initPromise
|
|
80
|
+
} finally {
|
|
81
|
+
initPromise = null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
65
84
|
|
|
85
|
+
async function doInitializeSearchIndex(): Promise<void> {
|
|
66
86
|
const srcDir = path.join(getProjectRoot(), 'src')
|
|
67
87
|
const searchDirs = [
|
|
68
88
|
path.join(srcDir, 'components'),
|
|
@@ -99,6 +119,9 @@ export async function initializeSearchIndex(): Promise<void> {
|
|
|
99
119
|
}
|
|
100
120
|
}))
|
|
101
121
|
|
|
122
|
+
// Index image-like values from content collection data files (JSON/YAML)
|
|
123
|
+
await indexContentCollectionImages()
|
|
124
|
+
|
|
102
125
|
setSearchIndexInitialized(true)
|
|
103
126
|
}
|
|
104
127
|
|
|
@@ -529,6 +552,117 @@ export function indexFileImages(cached: CachedParsedFile, relFile: string): void
|
|
|
529
552
|
}
|
|
530
553
|
}
|
|
531
554
|
|
|
555
|
+
// ============================================================================
|
|
556
|
+
// Content Collection Data File Indexing
|
|
557
|
+
// ============================================================================
|
|
558
|
+
|
|
559
|
+
/** Image-like file extensions to match in data file values */
|
|
560
|
+
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$/i
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Index image paths found in content collection data files (JSON/YAML).
|
|
564
|
+
* These are values like `"image": "/assets/photo.webp"` that get rendered
|
|
565
|
+
* through template expressions (e.g., `src={person.image}`).
|
|
566
|
+
*/
|
|
567
|
+
async function indexContentCollectionImages(): Promise<void> {
|
|
568
|
+
const contentDir = path.join(getProjectRoot(), 'src', 'content')
|
|
569
|
+
const entries = await fs.readdir(contentDir, { withFileTypes: true }).catch(() => null)
|
|
570
|
+
if (!entries) return // No content directory
|
|
571
|
+
|
|
572
|
+
const dataFiles: string[] = []
|
|
573
|
+
for (const entry of entries) {
|
|
574
|
+
if (entry.isDirectory()) {
|
|
575
|
+
await collectDataFiles(path.join(contentDir, entry.name), dataFiles)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
await Promise.all(dataFiles.map(async (filePath) => {
|
|
580
|
+
try {
|
|
581
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
582
|
+
const relFile = path.relative(getProjectRoot(), filePath)
|
|
583
|
+
|
|
584
|
+
if (filePath.endsWith('.json')) {
|
|
585
|
+
indexJsonImages(content, relFile)
|
|
586
|
+
} else if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
|
|
587
|
+
indexYamlImages(content, relFile)
|
|
588
|
+
} else if (filePath.endsWith('.md') || filePath.endsWith('.mdx')) {
|
|
589
|
+
indexFrontmatterImages(content, relFile)
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
// Skip unreadable files
|
|
593
|
+
}
|
|
594
|
+
}))
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const DATA_FILE_PATTERN = /\.(json|ya?ml|mdx?)$/
|
|
598
|
+
|
|
599
|
+
async function collectDataFiles(dir: string, results: string[]): Promise<void> {
|
|
600
|
+
try {
|
|
601
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
602
|
+
await Promise.all(entries.map(async (entry) => {
|
|
603
|
+
const fullPath = path.join(dir, entry.name)
|
|
604
|
+
if (entry.isDirectory()) {
|
|
605
|
+
await collectDataFiles(fullPath, results)
|
|
606
|
+
} else if (entry.isFile() && DATA_FILE_PATTERN.test(entry.name)) {
|
|
607
|
+
results.push(fullPath)
|
|
608
|
+
}
|
|
609
|
+
}))
|
|
610
|
+
} catch {
|
|
611
|
+
// Directory doesn't exist
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function indexJsonImages(content: string, relFile: string): void {
|
|
616
|
+
const lines = content.split('\n')
|
|
617
|
+
// Match JSON string values that look like image paths
|
|
618
|
+
const pattern = /:\s*"([^"]+)"/g
|
|
619
|
+
for (let i = 0; i < lines.length; i++) {
|
|
620
|
+
const line = lines[i]!
|
|
621
|
+
let match
|
|
622
|
+
pattern.lastIndex = 0
|
|
623
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
624
|
+
const value = match[1]!
|
|
625
|
+
if (IMAGE_EXTENSIONS.test(value)) {
|
|
626
|
+
addToImageSearchIndex({
|
|
627
|
+
file: relFile,
|
|
628
|
+
line: i + 1,
|
|
629
|
+
snippet: line.trim(),
|
|
630
|
+
src: value,
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function indexYamlImages(content: string, relFile: string): void {
|
|
638
|
+
indexYamlLikeLines(content.split('\n'), relFile, 0)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function indexFrontmatterImages(content: string, relFile: string): void {
|
|
642
|
+
// Only scan YAML frontmatter (between --- markers)
|
|
643
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
644
|
+
if (!fmMatch) return
|
|
645
|
+
indexYamlLikeLines(fmMatch[1]!.split('\n'), relFile, 1)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Shared YAML key-value image scanner used by both indexYamlImages and indexFrontmatterImages */
|
|
649
|
+
function indexYamlLikeLines(lines: string[], relFile: string, lineOffset: number): void {
|
|
650
|
+
const pattern = /^\s*[\w-]+:\s*(.+)/
|
|
651
|
+
for (let i = 0; i < lines.length; i++) {
|
|
652
|
+
const match = lines[i]!.match(pattern)
|
|
653
|
+
if (!match) continue
|
|
654
|
+
const value = match[1]!.trim().replace(/^['"]|['"]$/g, '')
|
|
655
|
+
if (IMAGE_EXTENSIONS.test(value)) {
|
|
656
|
+
addToImageSearchIndex({
|
|
657
|
+
file: relFile,
|
|
658
|
+
line: i + 1 + lineOffset,
|
|
659
|
+
snippet: lines[i]!.trim(),
|
|
660
|
+
src: value,
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
532
666
|
// ============================================================================
|
|
533
667
|
// Index Lookup
|
|
534
668
|
// ============================================================================
|
|
@@ -541,55 +675,51 @@ export function findInTextIndex(textContent: string, tag: string): SourceLocatio
|
|
|
541
675
|
const tagLower = tag.toLowerCase()
|
|
542
676
|
const index = getTextSearchIndex()
|
|
543
677
|
|
|
544
|
-
//
|
|
678
|
+
// Helper to build SourceLocation from a text index entry
|
|
679
|
+
const toLocation = (entry: SearchIndexEntry): SourceLocation => ({
|
|
680
|
+
file: entry.file,
|
|
681
|
+
line: entry.line,
|
|
682
|
+
snippet: entry.snippet,
|
|
683
|
+
openingTagSnippet: entry.openingTagSnippet,
|
|
684
|
+
type: entry.type,
|
|
685
|
+
variableName: entry.variableName,
|
|
686
|
+
definitionLine: entry.definitionLine,
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
// First try exact match with same tag — prefer collection data files
|
|
690
|
+
let bestMatch: SourceLocation | undefined
|
|
545
691
|
for (const entry of index) {
|
|
546
692
|
if (entry.tag === tagLower && entry.normalizedText === normalizedSearch) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
snippet: entry.snippet,
|
|
551
|
-
openingTagSnippet: entry.openingTagSnippet,
|
|
552
|
-
type: entry.type,
|
|
553
|
-
variableName: entry.variableName,
|
|
554
|
-
definitionLine: entry.definitionLine,
|
|
555
|
-
}
|
|
693
|
+
const result = toLocation(entry)
|
|
694
|
+
if (isCollectionFile(entry.file)) return result
|
|
695
|
+
bestMatch ??= result
|
|
556
696
|
}
|
|
557
697
|
}
|
|
698
|
+
if (bestMatch) return bestMatch
|
|
558
699
|
|
|
559
|
-
// Then try partial match for longer text
|
|
700
|
+
// Then try partial match for longer text — prefer collection data files
|
|
560
701
|
if (normalizedSearch.length > 10) {
|
|
561
702
|
const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
|
|
562
703
|
for (const entry of index) {
|
|
563
704
|
if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
snippet: entry.snippet,
|
|
568
|
-
openingTagSnippet: entry.openingTagSnippet,
|
|
569
|
-
type: entry.type,
|
|
570
|
-
variableName: entry.variableName,
|
|
571
|
-
definitionLine: entry.definitionLine,
|
|
572
|
-
}
|
|
705
|
+
const result = toLocation(entry)
|
|
706
|
+
if (isCollectionFile(entry.file)) return result
|
|
707
|
+
bestMatch ??= result
|
|
573
708
|
}
|
|
574
709
|
}
|
|
710
|
+
if (bestMatch) return bestMatch
|
|
575
711
|
}
|
|
576
712
|
|
|
577
|
-
// Try any tag match
|
|
713
|
+
// Try any tag match — prefer collection data files
|
|
578
714
|
for (const entry of index) {
|
|
579
715
|
if (entry.normalizedText === normalizedSearch) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
snippet: entry.snippet,
|
|
584
|
-
openingTagSnippet: entry.openingTagSnippet,
|
|
585
|
-
type: entry.type,
|
|
586
|
-
variableName: entry.variableName,
|
|
587
|
-
definitionLine: entry.definitionLine,
|
|
588
|
-
}
|
|
716
|
+
const result = toLocation(entry)
|
|
717
|
+
if (isCollectionFile(entry.file)) return result
|
|
718
|
+
bestMatch ??= result
|
|
589
719
|
}
|
|
590
720
|
}
|
|
591
721
|
|
|
592
|
-
return
|
|
722
|
+
return bestMatch
|
|
593
723
|
}
|
|
594
724
|
|
|
595
725
|
/**
|
|
@@ -609,17 +739,25 @@ function extractPathname(src: string): string {
|
|
|
609
739
|
export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
|
|
610
740
|
const index = getImageSearchIndex()
|
|
611
741
|
|
|
612
|
-
// Exact match
|
|
742
|
+
// Exact match — prefer collection data files (src/content/) over templates.
|
|
743
|
+
// The same image URL can appear in both a collection data file and a template
|
|
744
|
+
// that statically renders the collection. The data file is the authoritative source.
|
|
745
|
+
let bestMatch: SourceLocation | undefined
|
|
613
746
|
for (const entry of index) {
|
|
614
747
|
if (entry.src === imageSrc) {
|
|
615
|
-
|
|
748
|
+
const result: SourceLocation = {
|
|
616
749
|
file: entry.file,
|
|
617
750
|
line: entry.line,
|
|
618
751
|
snippet: entry.snippet,
|
|
619
752
|
type: 'static',
|
|
620
753
|
}
|
|
754
|
+
if (isCollectionFile(entry.file)) {
|
|
755
|
+
return result // Collection data file — always preferred
|
|
756
|
+
}
|
|
757
|
+
bestMatch ??= result // Keep first non-collection match as fallback
|
|
621
758
|
}
|
|
622
759
|
}
|
|
760
|
+
if (bestMatch) return bestMatch
|
|
623
761
|
|
|
624
762
|
// Fallback: path suffix matching for CDN-transformed URLs
|
|
625
763
|
// e.g., rendered src "/cdn-cgi/image/.../assets/photo.webp" should match
|
|
@@ -628,14 +766,18 @@ export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
|
|
|
628
766
|
for (const entry of index) {
|
|
629
767
|
const entryPath = extractPathname(entry.src)
|
|
630
768
|
if (entryPath.length > 5 && (targetPath.endsWith(entryPath) || entryPath.endsWith(targetPath))) {
|
|
631
|
-
|
|
769
|
+
const result: SourceLocation = {
|
|
632
770
|
file: entry.file,
|
|
633
771
|
line: entry.line,
|
|
634
772
|
snippet: entry.snippet,
|
|
635
773
|
type: 'static',
|
|
636
774
|
}
|
|
775
|
+
if (isCollectionFile(entry.file)) {
|
|
776
|
+
return result
|
|
777
|
+
}
|
|
778
|
+
bestMatch ??= result
|
|
637
779
|
}
|
|
638
780
|
}
|
|
639
781
|
|
|
640
|
-
return
|
|
782
|
+
return bestMatch
|
|
641
783
|
}
|