@nuasite/cms-marker 0.0.64 → 0.0.66
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/types/build-processor.d.ts +2 -1
- package/dist/types/build-processor.d.ts.map +1 -1
- package/dist/types/component-registry.d.ts.map +1 -1
- package/dist/types/config.d.ts +19 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/dev-middleware.d.ts +10 -2
- package/dist/types/dev-middleware.d.ts.map +1 -1
- package/dist/types/error-collector.d.ts +56 -0
- package/dist/types/error-collector.d.ts.map +1 -0
- package/dist/types/html-processor.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/manifest-writer.d.ts +11 -2
- package/dist/types/manifest-writer.d.ts.map +1 -1
- package/dist/types/source-finder.d.ts +18 -3
- package/dist/types/source-finder.d.ts.map +1 -1
- package/dist/types/tailwind-colors.d.ts +6 -30
- package/dist/types/tailwind-colors.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/types.d.ts +25 -9
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/vite-plugin.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/build-processor.ts +73 -19
- package/src/component-registry.ts +2 -0
- package/src/config.ts +29 -0
- package/src/dev-middleware.ts +12 -4
- package/src/error-collector.ts +106 -0
- package/src/html-processor.ts +55 -37
- package/src/index.ts +20 -4
- package/src/manifest-writer.ts +38 -7
- package/src/source-finder.ts +1003 -295
- package/src/tailwind-colors.ts +511 -121
- package/src/types.ts +27 -9
- package/src/vite-plugin.ts +4 -12
- package/dist/types/astro-transform.d.ts +0 -21
- package/dist/types/astro-transform.d.ts.map +0 -1
- package/src/astro-transform.ts +0 -205
package/src/source-finder.ts
CHANGED
|
@@ -1,8 +1,444 @@
|
|
|
1
|
+
import { parse as parseAstro } from '@astrojs/compiler'
|
|
2
|
+
import type { ComponentNode, ElementNode, Node as AstroNode, TextNode } from '@astrojs/compiler/types'
|
|
3
|
+
import { parse as parseBabel } from '@babel/parser'
|
|
4
|
+
import type * as t from '@babel/types'
|
|
1
5
|
import fs from 'node:fs/promises'
|
|
2
6
|
import path from 'node:path'
|
|
7
|
+
import { getProjectRoot } from './config'
|
|
8
|
+
import { getErrorCollector } from './error-collector'
|
|
3
9
|
import type { ManifestEntry } from './types'
|
|
4
10
|
import { generateSourceHash } from './utils'
|
|
5
11
|
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// File Parsing Cache - Avoid re-parsing the same files
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
interface CachedParsedFile {
|
|
17
|
+
content: string
|
|
18
|
+
lines: string[]
|
|
19
|
+
ast: AstroNode
|
|
20
|
+
frontmatterContent: string | null
|
|
21
|
+
frontmatterStartLine: number
|
|
22
|
+
variableDefinitions: VariableDefinition[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cache for parsed Astro files - cleared between builds */
|
|
26
|
+
const parsedFileCache = new Map<string, CachedParsedFile>()
|
|
27
|
+
|
|
28
|
+
/** Cache for directory listings - cleared between builds */
|
|
29
|
+
const directoryCache = new Map<string, string[]>()
|
|
30
|
+
|
|
31
|
+
/** Cache for markdown file contents - cleared between builds */
|
|
32
|
+
const markdownFileCache = new Map<string, { content: string; lines: string[] }>()
|
|
33
|
+
|
|
34
|
+
/** Pre-built search index for fast lookups */
|
|
35
|
+
interface SearchIndexEntry {
|
|
36
|
+
file: string
|
|
37
|
+
line: number
|
|
38
|
+
snippet: string
|
|
39
|
+
type: 'static' | 'variable' | 'prop' | 'computed'
|
|
40
|
+
variableName?: string
|
|
41
|
+
definitionLine?: number
|
|
42
|
+
normalizedText: string
|
|
43
|
+
tag: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ImageIndexEntry {
|
|
47
|
+
file: string
|
|
48
|
+
line: number
|
|
49
|
+
snippet: string
|
|
50
|
+
src: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Search indexes built once per build */
|
|
54
|
+
let textSearchIndex: SearchIndexEntry[] = []
|
|
55
|
+
let imageSearchIndex: ImageIndexEntry[] = []
|
|
56
|
+
let searchIndexInitialized = false
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Clear all caches - call at start of each build
|
|
60
|
+
*/
|
|
61
|
+
export function clearSourceFinderCache(): void {
|
|
62
|
+
parsedFileCache.clear()
|
|
63
|
+
directoryCache.clear()
|
|
64
|
+
markdownFileCache.clear()
|
|
65
|
+
textSearchIndex = []
|
|
66
|
+
imageSearchIndex = []
|
|
67
|
+
searchIndexInitialized = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Initialize search index by pre-scanning all source files.
|
|
72
|
+
* This is much faster than searching per-entry.
|
|
73
|
+
*/
|
|
74
|
+
export async function initializeSearchIndex(): Promise<void> {
|
|
75
|
+
if (searchIndexInitialized) return
|
|
76
|
+
|
|
77
|
+
const srcDir = path.join(getProjectRoot(), 'src')
|
|
78
|
+
const searchDirs = [
|
|
79
|
+
path.join(srcDir, 'components'),
|
|
80
|
+
path.join(srcDir, 'pages'),
|
|
81
|
+
path.join(srcDir, 'layouts'),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
// Collect all Astro files first
|
|
85
|
+
const allFiles: string[] = []
|
|
86
|
+
for (const dir of searchDirs) {
|
|
87
|
+
try {
|
|
88
|
+
const files = await collectAstroFiles(dir)
|
|
89
|
+
allFiles.push(...files)
|
|
90
|
+
} catch {
|
|
91
|
+
// Directory doesn't exist
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse all files in parallel and build indexes
|
|
96
|
+
await Promise.all(allFiles.map(async (filePath) => {
|
|
97
|
+
try {
|
|
98
|
+
const cached = await getCachedParsedFile(filePath)
|
|
99
|
+
if (!cached) return
|
|
100
|
+
|
|
101
|
+
const relFile = path.relative(getProjectRoot(), filePath)
|
|
102
|
+
|
|
103
|
+
// Index all text content from this file
|
|
104
|
+
indexFileContent(cached, relFile)
|
|
105
|
+
|
|
106
|
+
// Index all images from this file
|
|
107
|
+
indexFileImages(cached, relFile)
|
|
108
|
+
} catch {
|
|
109
|
+
// Skip files that fail to parse
|
|
110
|
+
}
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
searchIndexInitialized = true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collect all .astro files in a directory recursively
|
|
118
|
+
*/
|
|
119
|
+
async function collectAstroFiles(dir: string): Promise<string[]> {
|
|
120
|
+
const cached = directoryCache.get(dir)
|
|
121
|
+
if (cached) return cached
|
|
122
|
+
|
|
123
|
+
const results: string[] = []
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
127
|
+
|
|
128
|
+
await Promise.all(entries.map(async (entry) => {
|
|
129
|
+
const fullPath = path.join(dir, entry.name)
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
const subFiles = await collectAstroFiles(fullPath)
|
|
132
|
+
results.push(...subFiles)
|
|
133
|
+
} else if (entry.isFile() && (entry.name.endsWith('.astro') || entry.name.endsWith('.tsx') || entry.name.endsWith('.jsx'))) {
|
|
134
|
+
results.push(fullPath)
|
|
135
|
+
}
|
|
136
|
+
}))
|
|
137
|
+
} catch {
|
|
138
|
+
// Directory doesn't exist
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
directoryCache.set(dir, results)
|
|
142
|
+
return results
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get a cached parsed file, parsing it if not cached
|
|
147
|
+
*/
|
|
148
|
+
async function getCachedParsedFile(filePath: string): Promise<CachedParsedFile | null> {
|
|
149
|
+
const cached = parsedFileCache.get(filePath)
|
|
150
|
+
if (cached) return cached
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
154
|
+
const lines = content.split('\n')
|
|
155
|
+
|
|
156
|
+
// Only parse .astro files with AST
|
|
157
|
+
if (!filePath.endsWith('.astro')) {
|
|
158
|
+
// For tsx/jsx, just cache content/lines for regex search
|
|
159
|
+
const entry: CachedParsedFile = {
|
|
160
|
+
content,
|
|
161
|
+
lines,
|
|
162
|
+
ast: { type: 'root', children: [] } as unknown as AstroNode,
|
|
163
|
+
frontmatterContent: null,
|
|
164
|
+
frontmatterStartLine: 0,
|
|
165
|
+
variableDefinitions: [],
|
|
166
|
+
}
|
|
167
|
+
parsedFileCache.set(filePath, entry)
|
|
168
|
+
return entry
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { ast, frontmatterContent, frontmatterStartLine } = await parseAstroFile(content)
|
|
172
|
+
|
|
173
|
+
let variableDefinitions: VariableDefinition[] = []
|
|
174
|
+
if (frontmatterContent) {
|
|
175
|
+
const frontmatterAst = parseFrontmatter(frontmatterContent, filePath)
|
|
176
|
+
if (frontmatterAst) {
|
|
177
|
+
variableDefinitions = extractVariableDefinitions(frontmatterAst, frontmatterStartLine)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const entry: CachedParsedFile = {
|
|
182
|
+
content,
|
|
183
|
+
lines,
|
|
184
|
+
ast,
|
|
185
|
+
frontmatterContent,
|
|
186
|
+
frontmatterStartLine,
|
|
187
|
+
variableDefinitions,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
parsedFileCache.set(filePath, entry)
|
|
191
|
+
return entry
|
|
192
|
+
} catch {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Index all searchable text content from a parsed file
|
|
199
|
+
*/
|
|
200
|
+
function indexFileContent(cached: CachedParsedFile, relFile: string): void {
|
|
201
|
+
// Walk AST and collect all text elements
|
|
202
|
+
function visit(node: AstroNode) {
|
|
203
|
+
if ((node.type === 'element' || node.type === 'component')) {
|
|
204
|
+
const elemNode = node as ElementNode | ComponentNode
|
|
205
|
+
const tag = elemNode.name.toLowerCase()
|
|
206
|
+
const textContent = getTextContent(elemNode)
|
|
207
|
+
const normalizedText = normalizeText(textContent)
|
|
208
|
+
const line = elemNode.position?.start.line ?? 0
|
|
209
|
+
|
|
210
|
+
if (normalizedText && normalizedText.length >= 2) {
|
|
211
|
+
// Check for variable references
|
|
212
|
+
const exprInfo = hasExpressionChild(elemNode)
|
|
213
|
+
if (exprInfo.found && exprInfo.varNames.length > 0) {
|
|
214
|
+
for (const varName of exprInfo.varNames) {
|
|
215
|
+
for (const def of cached.variableDefinitions) {
|
|
216
|
+
if (def.name === varName || (def.parentName && def.name === varName)) {
|
|
217
|
+
const normalizedDef = normalizeText(def.value)
|
|
218
|
+
const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
|
|
219
|
+
const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
220
|
+
|
|
221
|
+
textSearchIndex.push({
|
|
222
|
+
file: relFile,
|
|
223
|
+
line: def.line,
|
|
224
|
+
snippet: cached.lines[def.line - 1] || '',
|
|
225
|
+
type: 'variable',
|
|
226
|
+
variableName: def.parentName ? `${def.parentName}.${def.name}` : def.name,
|
|
227
|
+
definitionLine: def.line,
|
|
228
|
+
normalizedText: normalizedDef,
|
|
229
|
+
tag,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Index static text content
|
|
237
|
+
const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
|
|
238
|
+
const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
239
|
+
|
|
240
|
+
textSearchIndex.push({
|
|
241
|
+
file: relFile,
|
|
242
|
+
line,
|
|
243
|
+
snippet,
|
|
244
|
+
type: 'static',
|
|
245
|
+
normalizedText,
|
|
246
|
+
tag,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Also index component props
|
|
251
|
+
if (node.type === 'component') {
|
|
252
|
+
for (const attr of elemNode.attributes) {
|
|
253
|
+
if (attr.type === 'attribute' && attr.kind === 'quoted' && attr.value) {
|
|
254
|
+
const normalizedValue = normalizeText(attr.value)
|
|
255
|
+
if (normalizedValue && normalizedValue.length >= 2) {
|
|
256
|
+
textSearchIndex.push({
|
|
257
|
+
file: relFile,
|
|
258
|
+
line: attr.position?.start.line ?? line,
|
|
259
|
+
snippet: cached.lines[(attr.position?.start.line ?? line) - 1] || '',
|
|
260
|
+
type: 'prop',
|
|
261
|
+
variableName: attr.name,
|
|
262
|
+
normalizedText: normalizedValue,
|
|
263
|
+
tag,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
272
|
+
for (const child of node.children) {
|
|
273
|
+
visit(child)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
visit(cached.ast)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Index all images from a parsed file
|
|
283
|
+
*/
|
|
284
|
+
function indexFileImages(cached: CachedParsedFile, relFile: string): void {
|
|
285
|
+
// For Astro files, use AST
|
|
286
|
+
if (relFile.endsWith('.astro')) {
|
|
287
|
+
function visit(node: AstroNode) {
|
|
288
|
+
if (node.type === 'element') {
|
|
289
|
+
const elemNode = node as ElementNode
|
|
290
|
+
if (elemNode.name.toLowerCase() === 'img') {
|
|
291
|
+
for (const attr of elemNode.attributes) {
|
|
292
|
+
if (attr.type === 'attribute' && attr.name === 'src' && attr.value) {
|
|
293
|
+
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
294
|
+
const snippet = extractImageSnippet(cached.lines, srcLine - 1)
|
|
295
|
+
imageSearchIndex.push({
|
|
296
|
+
file: relFile,
|
|
297
|
+
line: srcLine,
|
|
298
|
+
snippet,
|
|
299
|
+
src: attr.value,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
307
|
+
for (const child of node.children) {
|
|
308
|
+
visit(child)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
visit(cached.ast)
|
|
313
|
+
} else {
|
|
314
|
+
// For tsx/jsx, use regex
|
|
315
|
+
const srcPatterns = [/src="([^"]+)"/g, /src='([^']+)'/g]
|
|
316
|
+
for (let i = 0; i < cached.lines.length; i++) {
|
|
317
|
+
const line = cached.lines[i]
|
|
318
|
+
if (!line) continue
|
|
319
|
+
|
|
320
|
+
for (const pattern of srcPatterns) {
|
|
321
|
+
pattern.lastIndex = 0
|
|
322
|
+
let match: RegExpExecArray | null
|
|
323
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
324
|
+
const snippet = extractImageSnippet(cached.lines, i)
|
|
325
|
+
imageSearchIndex.push({
|
|
326
|
+
file: relFile,
|
|
327
|
+
line: i + 1,
|
|
328
|
+
snippet,
|
|
329
|
+
src: match[1]!,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Fast text lookup using pre-built index
|
|
339
|
+
*/
|
|
340
|
+
function findInTextIndex(textContent: string, tag: string): SourceLocation | undefined {
|
|
341
|
+
const normalizedSearch = normalizeText(textContent)
|
|
342
|
+
const tagLower = tag.toLowerCase()
|
|
343
|
+
|
|
344
|
+
// First try exact match with same tag
|
|
345
|
+
for (const entry of textSearchIndex) {
|
|
346
|
+
if (entry.tag === tagLower && entry.normalizedText === normalizedSearch) {
|
|
347
|
+
return {
|
|
348
|
+
file: entry.file,
|
|
349
|
+
line: entry.line,
|
|
350
|
+
snippet: entry.snippet,
|
|
351
|
+
type: entry.type,
|
|
352
|
+
variableName: entry.variableName,
|
|
353
|
+
definitionLine: entry.definitionLine,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Then try partial match for longer text
|
|
359
|
+
if (normalizedSearch.length > 10) {
|
|
360
|
+
const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
|
|
361
|
+
for (const entry of textSearchIndex) {
|
|
362
|
+
if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
|
|
363
|
+
return {
|
|
364
|
+
file: entry.file,
|
|
365
|
+
line: entry.line,
|
|
366
|
+
snippet: entry.snippet,
|
|
367
|
+
type: entry.type,
|
|
368
|
+
variableName: entry.variableName,
|
|
369
|
+
definitionLine: entry.definitionLine,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Try any tag match
|
|
376
|
+
for (const entry of textSearchIndex) {
|
|
377
|
+
if (entry.normalizedText === normalizedSearch) {
|
|
378
|
+
return {
|
|
379
|
+
file: entry.file,
|
|
380
|
+
line: entry.line,
|
|
381
|
+
snippet: entry.snippet,
|
|
382
|
+
type: entry.type,
|
|
383
|
+
variableName: entry.variableName,
|
|
384
|
+
definitionLine: entry.definitionLine,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Fast image lookup using pre-built index
|
|
394
|
+
*/
|
|
395
|
+
function findInImageIndex(imageSrc: string): SourceLocation | undefined {
|
|
396
|
+
for (const entry of imageSearchIndex) {
|
|
397
|
+
if (entry.src === imageSrc) {
|
|
398
|
+
return {
|
|
399
|
+
file: entry.file,
|
|
400
|
+
line: entry.line,
|
|
401
|
+
snippet: entry.snippet,
|
|
402
|
+
type: 'static',
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return undefined
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Helper for indexing - get text content from node
|
|
410
|
+
function getTextContent(node: AstroNode): string {
|
|
411
|
+
if (node.type === 'text') {
|
|
412
|
+
return (node as TextNode).value
|
|
413
|
+
}
|
|
414
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
415
|
+
return node.children.map(getTextContent).join('')
|
|
416
|
+
}
|
|
417
|
+
return ''
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Helper for indexing - check for expression children
|
|
421
|
+
function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
|
|
422
|
+
const varNames: string[] = []
|
|
423
|
+
if (node.type === 'expression') {
|
|
424
|
+
const exprText = getTextContent(node)
|
|
425
|
+
const match = exprText.match(/^\s*(\w+)(?:\.(\w+))?\s*$/)
|
|
426
|
+
if (match) {
|
|
427
|
+
varNames.push(match[2] ?? match[1]!)
|
|
428
|
+
}
|
|
429
|
+
return { found: true, varNames }
|
|
430
|
+
}
|
|
431
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
432
|
+
for (const child of node.children) {
|
|
433
|
+
const result = hasExpressionChild(child)
|
|
434
|
+
if (result.found) {
|
|
435
|
+
varNames.push(...result.varNames)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { found: varNames.length > 0, varNames }
|
|
440
|
+
}
|
|
441
|
+
|
|
6
442
|
export interface SourceLocation {
|
|
7
443
|
file: string
|
|
8
444
|
line: number
|
|
@@ -43,14 +479,429 @@ export interface MarkdownContent {
|
|
|
43
479
|
collectionSlug: string
|
|
44
480
|
}
|
|
45
481
|
|
|
482
|
+
// ============================================================================
|
|
483
|
+
// AST Parsing Utilities
|
|
484
|
+
// ============================================================================
|
|
485
|
+
|
|
486
|
+
interface ParsedAstroFile {
|
|
487
|
+
ast: AstroNode
|
|
488
|
+
frontmatterContent: string | null
|
|
489
|
+
frontmatterStartLine: number
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Parse an Astro file and return both template AST and frontmatter content
|
|
494
|
+
*/
|
|
495
|
+
async function parseAstroFile(content: string): Promise<ParsedAstroFile> {
|
|
496
|
+
const result = await parseAstro(content, { position: true })
|
|
497
|
+
|
|
498
|
+
// Find frontmatter node
|
|
499
|
+
let frontmatterContent: string | null = null
|
|
500
|
+
let frontmatterStartLine = 0
|
|
501
|
+
|
|
502
|
+
for (const child of result.ast.children) {
|
|
503
|
+
if (child.type === 'frontmatter') {
|
|
504
|
+
frontmatterContent = child.value
|
|
505
|
+
frontmatterStartLine = child.position?.start.line ?? 1
|
|
506
|
+
break
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
ast: result.ast,
|
|
512
|
+
frontmatterContent,
|
|
513
|
+
frontmatterStartLine,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Parse frontmatter JavaScript/TypeScript with Babel
|
|
519
|
+
* @param content - The frontmatter content to parse
|
|
520
|
+
* @param filePath - Optional file path for error reporting
|
|
521
|
+
*/
|
|
522
|
+
function parseFrontmatter(content: string, filePath?: string): t.File | null {
|
|
523
|
+
try {
|
|
524
|
+
return parseBabel(content, {
|
|
525
|
+
sourceType: 'module',
|
|
526
|
+
plugins: ['typescript'],
|
|
527
|
+
errorRecovery: true,
|
|
528
|
+
})
|
|
529
|
+
} catch (error) {
|
|
530
|
+
// Record parse errors for aggregated reporting
|
|
531
|
+
if (filePath) {
|
|
532
|
+
getErrorCollector().addWarning(
|
|
533
|
+
`Frontmatter parse: ${filePath}`,
|
|
534
|
+
error instanceof Error ? error.message : String(error),
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
return null
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
interface VariableDefinition {
|
|
542
|
+
name: string
|
|
543
|
+
value: string
|
|
544
|
+
line: number
|
|
545
|
+
/** For object properties, the parent variable name */
|
|
546
|
+
parentName?: string
|
|
547
|
+
}
|
|
548
|
+
|
|
46
549
|
/**
|
|
47
|
-
*
|
|
550
|
+
* Extract variable definitions from Babel AST
|
|
551
|
+
* Finds const/let/var declarations with string literal values
|
|
552
|
+
*
|
|
553
|
+
* Note: Babel parses the frontmatter content (without --- delimiters) starting at line 1.
|
|
554
|
+
* frontmatterStartLine is the actual file line where the content begins (after first ---).
|
|
555
|
+
* So we convert: file_line = (babel_line - 1) + frontmatterStartLine
|
|
556
|
+
*/
|
|
557
|
+
function extractVariableDefinitions(ast: t.File, frontmatterStartLine: number): VariableDefinition[] {
|
|
558
|
+
const definitions: VariableDefinition[] = []
|
|
559
|
+
|
|
560
|
+
function getStringValue(node: t.Node): string | null {
|
|
561
|
+
if (node.type === 'StringLiteral') {
|
|
562
|
+
return node.value
|
|
563
|
+
}
|
|
564
|
+
if (node.type === 'TemplateLiteral' && node.quasis.length === 1 && node.expressions.length === 0) {
|
|
565
|
+
return node.quasis[0]?.value.cooked ?? null
|
|
566
|
+
}
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function babelLineToFileLine(babelLine: number): number {
|
|
571
|
+
// Babel's line 1 = frontmatterStartLine in the actual file
|
|
572
|
+
return (babelLine - 1) + frontmatterStartLine
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function visitNode(node: t.Node) {
|
|
576
|
+
if (node.type === 'VariableDeclaration') {
|
|
577
|
+
for (const decl of node.declarations) {
|
|
578
|
+
if (decl.id.type === 'Identifier' && decl.init) {
|
|
579
|
+
const varName = decl.id.name
|
|
580
|
+
const line = babelLineToFileLine(decl.loc?.start.line ?? 1)
|
|
581
|
+
|
|
582
|
+
// Simple string value
|
|
583
|
+
const stringValue = getStringValue(decl.init)
|
|
584
|
+
if (stringValue !== null) {
|
|
585
|
+
definitions.push({ name: varName, value: stringValue, line })
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Object expression - extract properties
|
|
589
|
+
if (decl.init.type === 'ObjectExpression') {
|
|
590
|
+
for (const prop of decl.init.properties) {
|
|
591
|
+
if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier' && prop.value) {
|
|
592
|
+
const propValue = getStringValue(prop.value)
|
|
593
|
+
if (propValue !== null) {
|
|
594
|
+
const propLine = babelLineToFileLine(prop.loc?.start.line ?? 1)
|
|
595
|
+
definitions.push({
|
|
596
|
+
name: prop.key.name,
|
|
597
|
+
value: propValue,
|
|
598
|
+
line: propLine,
|
|
599
|
+
parentName: varName,
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Recursively visit child nodes
|
|
610
|
+
for (const key of Object.keys(node)) {
|
|
611
|
+
const value = (node as unknown as Record<string, unknown>)[key]
|
|
612
|
+
if (value && typeof value === 'object') {
|
|
613
|
+
if (Array.isArray(value)) {
|
|
614
|
+
for (const item of value) {
|
|
615
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
616
|
+
visitNode(item as t.Node)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} else if ('type' in value) {
|
|
620
|
+
visitNode(value as t.Node)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
visitNode(ast.program)
|
|
627
|
+
return definitions
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
interface TemplateMatch {
|
|
631
|
+
line: number
|
|
632
|
+
type: 'static' | 'variable' | 'computed'
|
|
633
|
+
variableName?: string
|
|
634
|
+
/** For variables, the definition line in frontmatter */
|
|
635
|
+
definitionLine?: number
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Walk the Astro AST to find elements matching a tag with specific text content
|
|
640
|
+
*/
|
|
641
|
+
function findElementWithText(
|
|
642
|
+
ast: AstroNode,
|
|
643
|
+
tag: string,
|
|
644
|
+
searchText: string,
|
|
645
|
+
variableDefinitions: VariableDefinition[],
|
|
646
|
+
): TemplateMatch | null {
|
|
647
|
+
const normalizedSearch = normalizeText(searchText)
|
|
648
|
+
const tagLower = tag.toLowerCase()
|
|
649
|
+
let bestMatch: TemplateMatch | null = null
|
|
650
|
+
let bestScore = 0
|
|
651
|
+
|
|
652
|
+
function getTextContent(node: AstroNode): string {
|
|
653
|
+
if (node.type === 'text') {
|
|
654
|
+
return (node as TextNode).value
|
|
655
|
+
}
|
|
656
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
657
|
+
return node.children.map(getTextContent).join('')
|
|
658
|
+
}
|
|
659
|
+
return ''
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
|
|
663
|
+
const varNames: string[] = []
|
|
664
|
+
if (node.type === 'expression') {
|
|
665
|
+
// Try to extract variable name from expression
|
|
666
|
+
// The expression node children contain the text representation
|
|
667
|
+
const exprText = getTextContent(node)
|
|
668
|
+
// Extract variable names like {foo} or {foo.bar}
|
|
669
|
+
const match = exprText.match(/^\s*(\w+)(?:\.(\w+))?\s*$/)
|
|
670
|
+
if (match) {
|
|
671
|
+
varNames.push(match[2] ?? match[1]!)
|
|
672
|
+
}
|
|
673
|
+
return { found: true, varNames }
|
|
674
|
+
}
|
|
675
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
676
|
+
for (const child of node.children) {
|
|
677
|
+
const result = hasExpressionChild(child)
|
|
678
|
+
if (result.found) {
|
|
679
|
+
varNames.push(...result.varNames)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return { found: varNames.length > 0, varNames }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function visit(node: AstroNode) {
|
|
687
|
+
// Check if this is an element or component matching our tag
|
|
688
|
+
if ((node.type === 'element' || node.type === 'component') && node.name.toLowerCase() === tagLower) {
|
|
689
|
+
const elemNode = node as ElementNode | ComponentNode
|
|
690
|
+
const textContent = getTextContent(elemNode)
|
|
691
|
+
const normalizedContent = normalizeText(textContent)
|
|
692
|
+
const line = elemNode.position?.start.line ?? 0
|
|
693
|
+
|
|
694
|
+
// Check for expression (variable reference)
|
|
695
|
+
const exprInfo = hasExpressionChild(elemNode)
|
|
696
|
+
if (exprInfo.found && exprInfo.varNames.length > 0) {
|
|
697
|
+
// Look for matching variable definition
|
|
698
|
+
for (const varName of exprInfo.varNames) {
|
|
699
|
+
for (const def of variableDefinitions) {
|
|
700
|
+
if (def.name === varName || (def.parentName && def.name === varName)) {
|
|
701
|
+
const normalizedDef = normalizeText(def.value)
|
|
702
|
+
if (normalizedDef === normalizedSearch) {
|
|
703
|
+
// Found a variable match - this is highest priority
|
|
704
|
+
if (bestScore < 100) {
|
|
705
|
+
bestScore = 100
|
|
706
|
+
bestMatch = {
|
|
707
|
+
line,
|
|
708
|
+
type: 'variable',
|
|
709
|
+
variableName: def.parentName ? `${def.parentName}.${def.name}` : def.name,
|
|
710
|
+
definitionLine: def.line,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Check for direct text match (static content)
|
|
721
|
+
// Only match if there's meaningful text content (not just variable names/expressions)
|
|
722
|
+
if (normalizedContent && normalizedContent.length >= 2 && normalizedSearch.length > 0) {
|
|
723
|
+
// For short search text (<= 10 chars), require exact match
|
|
724
|
+
if (normalizedSearch.length <= 10) {
|
|
725
|
+
if (normalizedContent.includes(normalizedSearch)) {
|
|
726
|
+
const score = 80
|
|
727
|
+
if (score > bestScore) {
|
|
728
|
+
bestScore = score
|
|
729
|
+
const actualLine = findTextLine(elemNode, normalizedSearch)
|
|
730
|
+
bestMatch = {
|
|
731
|
+
line: actualLine ?? line,
|
|
732
|
+
type: 'static',
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
} // For longer search text, check if content contains a significant portion
|
|
737
|
+
else if (normalizedSearch.length > 10) {
|
|
738
|
+
const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
|
|
739
|
+
if (normalizedContent.includes(textPreview)) {
|
|
740
|
+
const matchLength = Math.min(normalizedSearch.length, normalizedContent.length)
|
|
741
|
+
const score = 50 + (matchLength / normalizedSearch.length) * 40
|
|
742
|
+
if (score > bestScore) {
|
|
743
|
+
bestScore = score
|
|
744
|
+
const actualLine = findTextLine(elemNode, textPreview)
|
|
745
|
+
bestMatch = {
|
|
746
|
+
line: actualLine ?? line,
|
|
747
|
+
type: 'static',
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} // Try matching first few words for very long text
|
|
751
|
+
else if (normalizedSearch.length > 20) {
|
|
752
|
+
const firstWords = normalizedSearch.split(' ').slice(0, 3).join(' ')
|
|
753
|
+
if (firstWords && normalizedContent.includes(firstWords)) {
|
|
754
|
+
const score = 40
|
|
755
|
+
if (score > bestScore) {
|
|
756
|
+
bestScore = score
|
|
757
|
+
const actualLine = findTextLine(elemNode, firstWords)
|
|
758
|
+
bestMatch = {
|
|
759
|
+
line: actualLine ?? line,
|
|
760
|
+
type: 'static',
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Recursively visit children
|
|
770
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
771
|
+
for (const child of node.children) {
|
|
772
|
+
visit(child)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function findTextLine(node: AstroNode, searchText: string): number | null {
|
|
778
|
+
if (node.type === 'text') {
|
|
779
|
+
const textNode = node as TextNode
|
|
780
|
+
if (normalizeText(textNode.value).includes(searchText)) {
|
|
781
|
+
return textNode.position?.start.line ?? null
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
785
|
+
for (const child of node.children) {
|
|
786
|
+
const line = findTextLine(child, searchText)
|
|
787
|
+
if (line !== null) return line
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return null
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
visit(ast)
|
|
794
|
+
return bestMatch
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
interface ComponentPropMatch {
|
|
798
|
+
line: number
|
|
799
|
+
propName: string
|
|
800
|
+
propValue: string
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Walk the Astro AST to find component props with specific text value
|
|
805
|
+
*/
|
|
806
|
+
function findComponentProp(
|
|
807
|
+
ast: AstroNode,
|
|
808
|
+
searchText: string,
|
|
809
|
+
): ComponentPropMatch | null {
|
|
810
|
+
const normalizedSearch = normalizeText(searchText)
|
|
811
|
+
|
|
812
|
+
function visit(node: AstroNode): ComponentPropMatch | null {
|
|
813
|
+
// Check component nodes (PascalCase names)
|
|
814
|
+
if (node.type === 'component') {
|
|
815
|
+
const compNode = node as ComponentNode
|
|
816
|
+
for (const attr of compNode.attributes) {
|
|
817
|
+
if (attr.type === 'attribute' && attr.kind === 'quoted') {
|
|
818
|
+
const normalizedValue = normalizeText(attr.value)
|
|
819
|
+
if (normalizedValue === normalizedSearch) {
|
|
820
|
+
return {
|
|
821
|
+
line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
|
|
822
|
+
propName: attr.name,
|
|
823
|
+
propValue: attr.value,
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Recursively visit children
|
|
831
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
832
|
+
for (const child of node.children) {
|
|
833
|
+
const result = visit(child)
|
|
834
|
+
if (result) return result
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return null
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return visit(ast)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
interface ImageMatch {
|
|
845
|
+
line: number
|
|
846
|
+
src: string
|
|
847
|
+
snippet: string
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Walk the Astro AST to find img elements with specific src
|
|
852
|
+
*/
|
|
853
|
+
function findImageElement(
|
|
854
|
+
ast: AstroNode,
|
|
855
|
+
imageSrc: string,
|
|
856
|
+
lines: string[],
|
|
857
|
+
): ImageMatch | null {
|
|
858
|
+
function visit(node: AstroNode): ImageMatch | null {
|
|
859
|
+
if (node.type === 'element') {
|
|
860
|
+
const elemNode = node as ElementNode
|
|
861
|
+
if (elemNode.name.toLowerCase() === 'img') {
|
|
862
|
+
for (const attr of elemNode.attributes) {
|
|
863
|
+
if (attr.type === 'attribute' && attr.name === 'src' && attr.value === imageSrc) {
|
|
864
|
+
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
865
|
+
const snippet = extractImageSnippet(lines, srcLine - 1)
|
|
866
|
+
return {
|
|
867
|
+
line: srcLine,
|
|
868
|
+
src: imageSrc,
|
|
869
|
+
snippet,
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Recursively visit children
|
|
877
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
878
|
+
for (const child of node.children) {
|
|
879
|
+
const result = visit(child)
|
|
880
|
+
if (result) return result
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return null
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return visit(ast)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Find source file and line number for text content.
|
|
892
|
+
* Uses pre-built search index for fast lookups.
|
|
48
893
|
*/
|
|
49
894
|
export async function findSourceLocation(
|
|
50
895
|
textContent: string,
|
|
51
896
|
tag: string,
|
|
52
897
|
): Promise<SourceLocation | undefined> {
|
|
53
|
-
|
|
898
|
+
// Use index if available (much faster)
|
|
899
|
+
if (searchIndexInitialized) {
|
|
900
|
+
return findInTextIndex(textContent, tag)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Fallback to slow search if index not initialized
|
|
904
|
+
const srcDir = path.join(getProjectRoot(), 'src')
|
|
54
905
|
|
|
55
906
|
try {
|
|
56
907
|
const searchDirs = [
|
|
@@ -89,12 +940,19 @@ export async function findSourceLocation(
|
|
|
89
940
|
}
|
|
90
941
|
|
|
91
942
|
/**
|
|
92
|
-
* Find source file and line number for an image by its src attribute
|
|
943
|
+
* Find source file and line number for an image by its src attribute.
|
|
944
|
+
* Uses pre-built search index for fast lookups.
|
|
93
945
|
*/
|
|
94
946
|
export async function findImageSourceLocation(
|
|
95
947
|
imageSrc: string,
|
|
96
948
|
): Promise<SourceLocation | undefined> {
|
|
97
|
-
|
|
949
|
+
// Use index if available (much faster)
|
|
950
|
+
if (searchIndexInitialized) {
|
|
951
|
+
return findInImageIndex(imageSrc)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Fallback to slow search if index not initialized
|
|
955
|
+
const srcDir = path.join(getProjectRoot(), 'src')
|
|
98
956
|
|
|
99
957
|
try {
|
|
100
958
|
const searchDirs = [
|
|
@@ -149,17 +1007,35 @@ async function searchDirectoryForImage(
|
|
|
149
1007
|
}
|
|
150
1008
|
|
|
151
1009
|
/**
|
|
152
|
-
* Search a single file for an image with matching src
|
|
1010
|
+
* Search a single file for an image with matching src.
|
|
1011
|
+
* Uses caching for better performance.
|
|
153
1012
|
*/
|
|
154
1013
|
async function searchFileForImage(
|
|
155
1014
|
filePath: string,
|
|
156
1015
|
imageSrc: string,
|
|
157
1016
|
): Promise<SourceLocation | undefined> {
|
|
158
1017
|
try {
|
|
159
|
-
|
|
160
|
-
const
|
|
1018
|
+
// Use cached parsed file
|
|
1019
|
+
const cached = await getCachedParsedFile(filePath)
|
|
1020
|
+
if (!cached) return undefined
|
|
1021
|
+
|
|
1022
|
+
const { lines, ast } = cached
|
|
1023
|
+
|
|
1024
|
+
// Use AST parsing for Astro files
|
|
1025
|
+
if (filePath.endsWith('.astro')) {
|
|
1026
|
+
const imageMatch = findImageElement(ast, imageSrc, lines)
|
|
1027
|
+
|
|
1028
|
+
if (imageMatch) {
|
|
1029
|
+
return {
|
|
1030
|
+
file: path.relative(getProjectRoot(), filePath),
|
|
1031
|
+
line: imageMatch.line,
|
|
1032
|
+
snippet: imageMatch.snippet,
|
|
1033
|
+
type: 'static',
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
161
1037
|
|
|
162
|
-
//
|
|
1038
|
+
// Regex fallback for TSX/JSX files or if AST parsing failed
|
|
163
1039
|
const srcPatterns = [
|
|
164
1040
|
`src="${imageSrc}"`,
|
|
165
1041
|
`src='${imageSrc}'`,
|
|
@@ -175,7 +1051,7 @@ async function searchFileForImage(
|
|
|
175
1051
|
const snippet = extractImageSnippet(lines, i)
|
|
176
1052
|
|
|
177
1053
|
return {
|
|
178
|
-
file: path.relative(
|
|
1054
|
+
file: path.relative(getProjectRoot(), filePath),
|
|
179
1055
|
line: i + 1,
|
|
180
1056
|
snippet,
|
|
181
1057
|
type: 'static',
|
|
@@ -248,7 +1124,8 @@ async function searchDirectory(
|
|
|
248
1124
|
}
|
|
249
1125
|
|
|
250
1126
|
/**
|
|
251
|
-
* Search a single Astro file for matching content
|
|
1127
|
+
* Search a single Astro file for matching content using AST parsing.
|
|
1128
|
+
* Uses caching for better performance.
|
|
252
1129
|
*/
|
|
253
1130
|
async function searchAstroFile(
|
|
254
1131
|
filePath: string,
|
|
@@ -256,106 +1133,25 @@ async function searchAstroFile(
|
|
|
256
1133
|
tag: string,
|
|
257
1134
|
): Promise<SourceLocation | undefined> {
|
|
258
1135
|
try {
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
const cleanText = normalizeText(textContent)
|
|
263
|
-
const textPreview = cleanText.slice(0, Math.min(30, cleanText.length))
|
|
264
|
-
|
|
265
|
-
// Extract variable references from frontmatter
|
|
266
|
-
const variableRefs = extractVariableReferences(content, cleanText)
|
|
267
|
-
|
|
268
|
-
// Collect all potential matches with scores and metadata
|
|
269
|
-
const matches: Array<{
|
|
270
|
-
line: number
|
|
271
|
-
score: number
|
|
272
|
-
type: 'static' | 'variable' | 'prop' | 'computed'
|
|
273
|
-
variableName?: string
|
|
274
|
-
definitionLine?: number
|
|
275
|
-
}> = []
|
|
276
|
-
|
|
277
|
-
// Search for tag usage with matching text or variable
|
|
278
|
-
for (let i = 0; i < lines.length; i++) {
|
|
279
|
-
const line = lines[i]?.trim().toLowerCase()
|
|
280
|
-
|
|
281
|
-
// Look for opening tag
|
|
282
|
-
if (line?.includes(`<${tag.toLowerCase()}`) && !line.startsWith(`</${tag.toLowerCase()}`)) {
|
|
283
|
-
// Collect content from this line and next few lines
|
|
284
|
-
const section = collectSection(lines, i, 5)
|
|
285
|
-
const sectionText = section.toLowerCase()
|
|
286
|
-
const sectionTextOnly = stripHtmlTags(section).toLowerCase()
|
|
287
|
-
|
|
288
|
-
let score = 0
|
|
289
|
-
let matched = false
|
|
290
|
-
|
|
291
|
-
// Check for variable reference match (highest priority)
|
|
292
|
-
if (variableRefs.length > 0) {
|
|
293
|
-
for (const varRef of variableRefs) {
|
|
294
|
-
// Check case-insensitively since sectionText is lowercased
|
|
295
|
-
if (sectionText.includes(`{`) && sectionText.includes(varRef.name.toLowerCase())) {
|
|
296
|
-
score = 100
|
|
297
|
-
matched = true
|
|
298
|
-
// Store match metadata - this is the USAGE line, we need DEFINITION line
|
|
299
|
-
matches.push({
|
|
300
|
-
line: i + 1,
|
|
301
|
-
score,
|
|
302
|
-
type: 'variable',
|
|
303
|
-
variableName: varRef.name,
|
|
304
|
-
definitionLine: varRef.definitionLine,
|
|
305
|
-
})
|
|
306
|
-
break
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Check for direct text match (static content)
|
|
312
|
-
if (!matched && cleanText.length > 10 && sectionTextOnly.includes(textPreview)) {
|
|
313
|
-
// Score based on how much of the text matches
|
|
314
|
-
const matchLength = Math.min(cleanText.length, sectionTextOnly.length)
|
|
315
|
-
score = 50 + (matchLength / cleanText.length) * 40
|
|
316
|
-
matched = true
|
|
317
|
-
// Find the actual line containing the text
|
|
318
|
-
const actualLine = findLineContainingText(lines, i, 5, textPreview)
|
|
319
|
-
matches.push({ line: actualLine, score, type: 'static' })
|
|
320
|
-
}
|
|
1136
|
+
// Use cached parsed file
|
|
1137
|
+
const cached = await getCachedParsedFile(filePath)
|
|
1138
|
+
if (!cached) return undefined
|
|
321
1139
|
|
|
322
|
-
|
|
323
|
-
if (!matched && cleanText.length > 0 && cleanText.length <= 10 && sectionTextOnly.includes(cleanText)) {
|
|
324
|
-
score = 80
|
|
325
|
-
matched = true
|
|
326
|
-
// Find the actual line containing the text
|
|
327
|
-
const actualLine = findLineContainingText(lines, i, 5, cleanText)
|
|
328
|
-
matches.push({ line: actualLine, score, type: 'static' })
|
|
329
|
-
}
|
|
1140
|
+
const { lines, ast, variableDefinitions } = cached
|
|
330
1141
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const firstWords = cleanText.split(' ').slice(0, 3).join(' ')
|
|
334
|
-
if (firstWords && sectionTextOnly.includes(firstWords)) {
|
|
335
|
-
score = 40
|
|
336
|
-
matched = true
|
|
337
|
-
// Find the actual line containing the text
|
|
338
|
-
const actualLine = findLineContainingText(lines, i, 5, firstWords)
|
|
339
|
-
matches.push({ line: actualLine, score, type: 'static' })
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Return the best match (highest score)
|
|
346
|
-
if (matches.length > 0) {
|
|
347
|
-
const bestMatch = matches.reduce((best, current) => current.score > best.score ? current : best)
|
|
1142
|
+
// Find matching element in template AST
|
|
1143
|
+
const match = findElementWithText(ast, tag, textContent, variableDefinitions)
|
|
348
1144
|
|
|
1145
|
+
if (match) {
|
|
349
1146
|
// Determine the editable line (definition for variables, usage for static)
|
|
350
|
-
const editableLine =
|
|
351
|
-
?
|
|
352
|
-
:
|
|
1147
|
+
const editableLine = match.type === 'variable' && match.definitionLine
|
|
1148
|
+
? match.definitionLine
|
|
1149
|
+
: match.line
|
|
353
1150
|
|
|
354
1151
|
// Get the source snippet - innerHTML for static content, definition line for variables
|
|
355
1152
|
let snippet: string
|
|
356
|
-
if (
|
|
1153
|
+
if (match.type === 'static') {
|
|
357
1154
|
// For static content, extract only the innerHTML (not the wrapper element)
|
|
358
|
-
// This ensures that when replacing, we only replace the content, not the element structure
|
|
359
1155
|
const completeSnippet = extractCompleteTagSnippet(lines, editableLine - 1, tag)
|
|
360
1156
|
snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
361
1157
|
} else {
|
|
@@ -364,27 +1160,27 @@ async function searchAstroFile(
|
|
|
364
1160
|
}
|
|
365
1161
|
|
|
366
1162
|
return {
|
|
367
|
-
file: path.relative(
|
|
1163
|
+
file: path.relative(getProjectRoot(), filePath),
|
|
368
1164
|
line: editableLine,
|
|
369
1165
|
snippet,
|
|
370
|
-
type:
|
|
371
|
-
variableName:
|
|
372
|
-
definitionLine:
|
|
1166
|
+
type: match.type,
|
|
1167
|
+
variableName: match.variableName,
|
|
1168
|
+
definitionLine: match.type === 'variable' ? match.definitionLine : undefined,
|
|
373
1169
|
}
|
|
374
1170
|
}
|
|
375
1171
|
} catch {
|
|
376
|
-
// Error reading file
|
|
1172
|
+
// Error reading/parsing file
|
|
377
1173
|
}
|
|
378
1174
|
|
|
379
1175
|
return undefined
|
|
380
1176
|
}
|
|
381
1177
|
|
|
382
1178
|
/**
|
|
383
|
-
* Search for prop values passed to components
|
|
1179
|
+
* Search for prop values passed to components using AST parsing.
|
|
1180
|
+
* Uses caching for better performance.
|
|
384
1181
|
*/
|
|
385
1182
|
async function searchForPropInParents(dir: string, textContent: string): Promise<SourceLocation | undefined> {
|
|
386
1183
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
387
|
-
const cleanText = normalizeText(textContent)
|
|
388
1184
|
|
|
389
1185
|
for (const entry of entries) {
|
|
390
1186
|
const fullPath = path.join(dir, entry.name)
|
|
@@ -393,90 +1189,52 @@ async function searchForPropInParents(dir: string, textContent: string): Promise
|
|
|
393
1189
|
const result = await searchForPropInParents(fullPath, textContent)
|
|
394
1190
|
if (result) return result
|
|
395
1191
|
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
for (let i = 0; i < lines.length; i++) {
|
|
401
|
-
const line = lines[i]
|
|
402
|
-
|
|
403
|
-
// Match component usage like <ComponentName propName="value" />
|
|
404
|
-
const componentMatch = line?.match(/<([A-Z]\w+)/)
|
|
405
|
-
if (!componentMatch) continue
|
|
406
|
-
|
|
407
|
-
// Collect only the opening tag (until first > or />), not nested content
|
|
408
|
-
let openingTag = ''
|
|
409
|
-
let endLine = i
|
|
410
|
-
for (let j = i; j < Math.min(i + 10, lines.length); j++) {
|
|
411
|
-
openingTag += ' ' + lines[j]
|
|
412
|
-
endLine = j
|
|
413
|
-
|
|
414
|
-
// Stop at the end of opening tag (either /> or >)
|
|
415
|
-
if (lines[j]?.includes('/>')) {
|
|
416
|
-
// Self-closing tag
|
|
417
|
-
break
|
|
418
|
-
} else if (lines[j]?.includes('>')) {
|
|
419
|
-
// Opening tag ends here, don't include nested content
|
|
420
|
-
// Truncate to just the opening tag part
|
|
421
|
-
const tagEndIndex = openingTag.indexOf('>')
|
|
422
|
-
if (tagEndIndex !== -1) {
|
|
423
|
-
openingTag = openingTag.substring(0, tagEndIndex + 1)
|
|
424
|
-
}
|
|
425
|
-
break
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Extract all prop values from the opening tag only
|
|
430
|
-
const propMatches = openingTag.matchAll(/(\w+)=["']([^"']+)["']/g)
|
|
431
|
-
for (const match of propMatches) {
|
|
432
|
-
const propName = match[1]
|
|
433
|
-
const propValue = match[2]
|
|
1192
|
+
try {
|
|
1193
|
+
// Use cached parsed file
|
|
1194
|
+
const cached = await getCachedParsedFile(fullPath)
|
|
1195
|
+
if (!cached) continue
|
|
434
1196
|
|
|
435
|
-
|
|
436
|
-
continue
|
|
437
|
-
}
|
|
1197
|
+
const { lines, ast } = cached
|
|
438
1198
|
|
|
439
|
-
|
|
1199
|
+
// Find component props matching our text
|
|
1200
|
+
const propMatch = findComponentProp(ast, textContent)
|
|
440
1201
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
1202
|
+
if (propMatch) {
|
|
1203
|
+
// Extract component snippet for context
|
|
1204
|
+
const componentStart = propMatch.line - 1
|
|
1205
|
+
const snippetLines: string[] = []
|
|
1206
|
+
let depth = 0
|
|
444
1207
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
1208
|
+
for (let i = componentStart; i < Math.min(componentStart + 10, lines.length); i++) {
|
|
1209
|
+
const line = lines[i]
|
|
1210
|
+
if (!line) continue
|
|
1211
|
+
snippetLines.push(line)
|
|
450
1212
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
1213
|
+
// Check for self-closing or end of opening tag
|
|
1214
|
+
if (line.includes('/>')) {
|
|
1215
|
+
break
|
|
455
1216
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
1217
|
+
if (line.includes('>') && !line.includes('/>')) {
|
|
1218
|
+
// Count opening tags
|
|
1219
|
+
const opens = (line.match(/<[A-Z]/g) || []).length
|
|
1220
|
+
const closes = (line.match(/\/>/g) || []).length
|
|
1221
|
+
depth += opens - closes
|
|
1222
|
+
if (depth <= 0 || (i > componentStart && line.includes('>'))) {
|
|
1223
|
+
break
|
|
463
1224
|
}
|
|
464
|
-
|
|
465
|
-
componentSnippetLines.push(line)
|
|
466
1225
|
}
|
|
1226
|
+
}
|
|
467
1227
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
snippet: propSnippet,
|
|
475
|
-
type: 'prop',
|
|
476
|
-
variableName: propName,
|
|
477
|
-
}
|
|
1228
|
+
return {
|
|
1229
|
+
file: path.relative(getProjectRoot(), fullPath),
|
|
1230
|
+
line: propMatch.line,
|
|
1231
|
+
snippet: snippetLines.join('\n'),
|
|
1232
|
+
type: 'prop',
|
|
1233
|
+
variableName: propMatch.propName,
|
|
478
1234
|
}
|
|
479
1235
|
}
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Error parsing file, continue
|
|
480
1238
|
}
|
|
481
1239
|
}
|
|
482
1240
|
}
|
|
@@ -487,14 +1245,38 @@ async function searchForPropInParents(dir: string, textContent: string): Promise
|
|
|
487
1245
|
/**
|
|
488
1246
|
* Extract complete tag snippet including content and indentation.
|
|
489
1247
|
* Exported for use in html-processor to populate sourceSnippet.
|
|
1248
|
+
*
|
|
1249
|
+
* When startLine points to a line inside the element (e.g., the text content line),
|
|
1250
|
+
* this function searches backwards to find the opening tag first.
|
|
490
1251
|
*/
|
|
491
1252
|
export function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
|
|
1253
|
+
// Pattern to match opening tag - either followed by whitespace/>, or at end of line (multi-line tag)
|
|
1254
|
+
const openTagPattern = new RegExp(`<${tag}(?:[\\s>]|$)`, 'gi')
|
|
1255
|
+
|
|
1256
|
+
// Check if the start line contains the opening tag
|
|
1257
|
+
let actualStartLine = startLine
|
|
1258
|
+
const startLineContent = lines[startLine] || ''
|
|
1259
|
+
if (!openTagPattern.test(startLineContent)) {
|
|
1260
|
+
// Search backwards to find the opening tag
|
|
1261
|
+
for (let i = startLine - 1; i >= Math.max(0, startLine - 20); i--) {
|
|
1262
|
+
const line = lines[i]
|
|
1263
|
+
if (!line) continue
|
|
1264
|
+
|
|
1265
|
+
// Reset regex lastIndex for fresh test
|
|
1266
|
+
openTagPattern.lastIndex = 0
|
|
1267
|
+
if (openTagPattern.test(line)) {
|
|
1268
|
+
actualStartLine = i
|
|
1269
|
+
break
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
492
1274
|
const snippetLines: string[] = []
|
|
493
1275
|
let depth = 0
|
|
494
1276
|
let foundClosing = false
|
|
495
1277
|
|
|
496
1278
|
// Start from the opening tag line
|
|
497
|
-
for (let i =
|
|
1279
|
+
for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
|
|
498
1280
|
const line = lines[i]
|
|
499
1281
|
|
|
500
1282
|
if (!line) {
|
|
@@ -504,7 +1286,8 @@ export function extractCompleteTagSnippet(lines: string[], startLine: number, ta
|
|
|
504
1286
|
snippetLines.push(line)
|
|
505
1287
|
|
|
506
1288
|
// Count opening and closing tags
|
|
507
|
-
|
|
1289
|
+
// Opening tag can be followed by whitespace, >, or end of line (multi-line tag)
|
|
1290
|
+
const openTags = (line.match(new RegExp(`<${tag}(?:[\\s>]|$)`, 'gi')) || []).length
|
|
508
1291
|
const selfClosing = (line.match(new RegExp(`<${tag}[^>]*/>`, 'gi')) || []).length
|
|
509
1292
|
const closeTags = (line.match(new RegExp(`</${tag}>`, 'gi')) || []).length
|
|
510
1293
|
|
|
@@ -572,7 +1355,7 @@ export async function extractSourceInnerHtml(
|
|
|
572
1355
|
try {
|
|
573
1356
|
const filePath = path.isAbsolute(sourceFile)
|
|
574
1357
|
? sourceFile
|
|
575
|
-
: path.join(
|
|
1358
|
+
: path.join(getProjectRoot(), sourceFile)
|
|
576
1359
|
|
|
577
1360
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
578
1361
|
const lines = content.split('\n')
|
|
@@ -587,104 +1370,6 @@ export async function extractSourceInnerHtml(
|
|
|
587
1370
|
}
|
|
588
1371
|
}
|
|
589
1372
|
|
|
590
|
-
/**
|
|
591
|
-
* Extract variable references from frontmatter
|
|
592
|
-
*/
|
|
593
|
-
function extractVariableReferences(content: string, targetText: string): VariableReference[] {
|
|
594
|
-
const refs: VariableReference[] = []
|
|
595
|
-
const frontmatterEnd = content.indexOf('---', 3)
|
|
596
|
-
|
|
597
|
-
if (frontmatterEnd <= 0) return refs
|
|
598
|
-
|
|
599
|
-
const frontmatter = content.substring(0, frontmatterEnd)
|
|
600
|
-
const lines = frontmatter.split('\n')
|
|
601
|
-
|
|
602
|
-
for (const line of lines) {
|
|
603
|
-
const trimmed = line.trim()
|
|
604
|
-
|
|
605
|
-
// Match quoted text (handling escaped quotes)
|
|
606
|
-
// Try single quotes with escaped quotes
|
|
607
|
-
let quotedMatch = trimmed.match(/'((?:[^'\\]|\\.)*)'/)
|
|
608
|
-
if (!quotedMatch) {
|
|
609
|
-
// Try double quotes with escaped quotes
|
|
610
|
-
quotedMatch = trimmed.match(/"((?:[^"\\]|\\.)*)"/)
|
|
611
|
-
}
|
|
612
|
-
if (!quotedMatch) {
|
|
613
|
-
// Try backticks (template literals) - but only if no ${} interpolation
|
|
614
|
-
const backtickMatch = trimmed.match(/`([^`]*)`/)
|
|
615
|
-
if (backtickMatch && !backtickMatch[1]?.includes('${')) {
|
|
616
|
-
quotedMatch = backtickMatch
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
if (!quotedMatch?.[1]) continue
|
|
620
|
-
|
|
621
|
-
const value = normalizeText(quotedMatch[1])
|
|
622
|
-
const normalizedTarget = normalizeText(targetText)
|
|
623
|
-
|
|
624
|
-
if (value !== normalizedTarget) continue
|
|
625
|
-
|
|
626
|
-
// Try to extract variable name and line number
|
|
627
|
-
const lineNumber = lines.indexOf(line) + 1
|
|
628
|
-
|
|
629
|
-
// Pattern 1: Object property "key: 'value'"
|
|
630
|
-
const propMatch = trimmed.match(/(\w+)\s*:\s*['"`]/)
|
|
631
|
-
if (propMatch?.[1]) {
|
|
632
|
-
refs.push({
|
|
633
|
-
name: propMatch[1],
|
|
634
|
-
pattern: `{.*${propMatch[1]}`,
|
|
635
|
-
definitionLine: lineNumber,
|
|
636
|
-
})
|
|
637
|
-
continue
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Pattern 2: Variable declaration "const name = 'value'"
|
|
641
|
-
const varMatch = trimmed.match(/(?:const|let|var)\s+(\w+)(?:\s*:\s*\w+)?\s*=/)
|
|
642
|
-
if (varMatch?.[1]) {
|
|
643
|
-
refs.push({
|
|
644
|
-
name: varMatch[1],
|
|
645
|
-
pattern: `{${varMatch[1]}}`,
|
|
646
|
-
definitionLine: lineNumber,
|
|
647
|
-
})
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
return refs
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Collect text from multiple lines
|
|
656
|
-
*/
|
|
657
|
-
function collectSection(lines: string[], startLine: number, numLines: number): string {
|
|
658
|
-
let text = ''
|
|
659
|
-
for (let i = startLine; i < Math.min(startLine + numLines, lines.length); i++) {
|
|
660
|
-
text += ' ' + lines[i]?.trim().replace(/\s+/g, ' ')
|
|
661
|
-
}
|
|
662
|
-
return text
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Find the actual line containing the matched text within a section
|
|
667
|
-
* Returns 1-indexed line number
|
|
668
|
-
*/
|
|
669
|
-
function findLineContainingText(lines: string[], startLine: number, numLines: number, searchText: string): number {
|
|
670
|
-
const normalizedSearch = searchText.toLowerCase()
|
|
671
|
-
for (let i = startLine; i < Math.min(startLine + numLines, lines.length); i++) {
|
|
672
|
-
const lineText = stripHtmlTags(lines[i] || '').toLowerCase()
|
|
673
|
-
if (lineText.includes(normalizedSearch)) {
|
|
674
|
-
return i + 1 // Return 1-indexed line number
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
// If not found on a specific line, return the opening tag line
|
|
678
|
-
return startLine + 1
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/**
|
|
682
|
-
* Strip HTML tags from text
|
|
683
|
-
*/
|
|
684
|
-
function stripHtmlTags(text: string): string {
|
|
685
|
-
return text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
686
|
-
}
|
|
687
|
-
|
|
688
1373
|
/**
|
|
689
1374
|
* Normalize text for comparison (handles escaping and entities)
|
|
690
1375
|
*/
|
|
@@ -720,7 +1405,7 @@ export async function findCollectionSource(
|
|
|
720
1405
|
return undefined
|
|
721
1406
|
}
|
|
722
1407
|
|
|
723
|
-
const contentPath = path.join(
|
|
1408
|
+
const contentPath = path.join(getProjectRoot(), contentDir)
|
|
724
1409
|
|
|
725
1410
|
try {
|
|
726
1411
|
// Check if content directory exists
|
|
@@ -757,7 +1442,7 @@ export async function findCollectionSource(
|
|
|
757
1442
|
return {
|
|
758
1443
|
name: collectionName,
|
|
759
1444
|
slug,
|
|
760
|
-
file: path.relative(
|
|
1445
|
+
file: path.relative(getProjectRoot(), mdFile),
|
|
761
1446
|
}
|
|
762
1447
|
}
|
|
763
1448
|
|
|
@@ -820,6 +1505,24 @@ async function findMarkdownFile(collectionPath: string, slug: string): Promise<s
|
|
|
820
1505
|
return undefined
|
|
821
1506
|
}
|
|
822
1507
|
|
|
1508
|
+
/**
|
|
1509
|
+
* Get cached markdown file content
|
|
1510
|
+
*/
|
|
1511
|
+
async function getCachedMarkdownFile(filePath: string): Promise<{ content: string; lines: string[] } | null> {
|
|
1512
|
+
const cached = markdownFileCache.get(filePath)
|
|
1513
|
+
if (cached) return cached
|
|
1514
|
+
|
|
1515
|
+
try {
|
|
1516
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
1517
|
+
const lines = content.split('\n')
|
|
1518
|
+
const entry = { content, lines }
|
|
1519
|
+
markdownFileCache.set(filePath, entry)
|
|
1520
|
+
return entry
|
|
1521
|
+
} catch {
|
|
1522
|
+
return null
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
823
1526
|
/**
|
|
824
1527
|
* Find text content in a markdown file and return source location
|
|
825
1528
|
* Only matches frontmatter fields, not body content (body is handled separately as a whole)
|
|
@@ -832,9 +1535,11 @@ export async function findMarkdownSourceLocation(
|
|
|
832
1535
|
collectionInfo: CollectionInfo,
|
|
833
1536
|
): Promise<SourceLocation | undefined> {
|
|
834
1537
|
try {
|
|
835
|
-
const filePath = path.join(
|
|
836
|
-
const
|
|
837
|
-
|
|
1538
|
+
const filePath = path.join(getProjectRoot(), collectionInfo.file)
|
|
1539
|
+
const cached = await getCachedMarkdownFile(filePath)
|
|
1540
|
+
if (!cached) return undefined
|
|
1541
|
+
|
|
1542
|
+
const { lines } = cached
|
|
838
1543
|
const normalizedSearch = normalizeText(textContent)
|
|
839
1544
|
|
|
840
1545
|
// Parse frontmatter
|
|
@@ -898,7 +1603,8 @@ export async function findMarkdownSourceLocation(
|
|
|
898
1603
|
}
|
|
899
1604
|
|
|
900
1605
|
/**
|
|
901
|
-
* Parse markdown file and extract frontmatter fields and full body content
|
|
1606
|
+
* Parse markdown file and extract frontmatter fields and full body content.
|
|
1607
|
+
* Uses caching for better performance.
|
|
902
1608
|
* @param collectionInfo - Collection information (name, slug, file path)
|
|
903
1609
|
* @returns Parsed markdown content with frontmatter and body
|
|
904
1610
|
*/
|
|
@@ -906,9 +1612,11 @@ export async function parseMarkdownContent(
|
|
|
906
1612
|
collectionInfo: CollectionInfo,
|
|
907
1613
|
): Promise<MarkdownContent | undefined> {
|
|
908
1614
|
try {
|
|
909
|
-
const filePath = path.join(
|
|
910
|
-
const
|
|
911
|
-
|
|
1615
|
+
const filePath = path.join(getProjectRoot(), collectionInfo.file)
|
|
1616
|
+
const cached = await getCachedMarkdownFile(filePath)
|
|
1617
|
+
if (!cached) return undefined
|
|
1618
|
+
|
|
1619
|
+
const { lines } = cached
|
|
912
1620
|
|
|
913
1621
|
// Parse frontmatter
|
|
914
1622
|
let frontmatterStart = -1
|
|
@@ -1008,10 +1716,10 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
1008
1716
|
// Process entries in parallel for better performance
|
|
1009
1717
|
const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
|
|
1010
1718
|
// Handle image entries specially - find the line with src attribute
|
|
1011
|
-
if (entry.sourceType === 'image' && entry.
|
|
1012
|
-
const imageLocation = await findImageSourceLocation(entry.
|
|
1719
|
+
if (entry.sourceType === 'image' && entry.imageMetadata?.src) {
|
|
1720
|
+
const imageLocation = await findImageSourceLocation(entry.imageMetadata.src)
|
|
1013
1721
|
if (imageLocation) {
|
|
1014
|
-
const sourceHash = generateSourceHash(imageLocation.snippet || entry.
|
|
1722
|
+
const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
|
|
1015
1723
|
return [id, {
|
|
1016
1724
|
...entry,
|
|
1017
1725
|
sourcePath: imageLocation.file,
|