@nuasite/cms 0.13.3 → 0.14.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 +3944 -3899
- package/package.json +6 -6
- package/src/dev-middleware.ts +8 -1
- package/src/editor/components/collections-browser.tsx +62 -55
- package/src/handlers/source-writer.ts +45 -29
- package/src/source-finder/search-index.ts +141 -25
- package/src/vite-plugin.ts +2 -2
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.14.1",
|
|
18
18
|
"module": "src/index.ts",
|
|
19
19
|
"types": "src/index.ts",
|
|
20
20
|
"type": "module",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@astrojs/compiler": "^
|
|
29
|
+
"@astrojs/compiler": "^3.0.0",
|
|
30
30
|
"@babel/parser": "^7.29.0",
|
|
31
31
|
"node-html-parser": "^7.1.0",
|
|
32
32
|
"yaml": "^2.8.2"
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"@milkdown/preset-gfm": "^7.19.0",
|
|
40
40
|
"@milkdown/prose": "^7.19.0",
|
|
41
41
|
"@milkdown/utils": "^7.19.0",
|
|
42
|
-
"@preact/signals": "^2.8.
|
|
42
|
+
"@preact/signals": "^2.8.2",
|
|
43
43
|
"@tailwindcss/vite": "^4.2.1",
|
|
44
44
|
"@types/bun": "1.3.10",
|
|
45
45
|
"clsx": "^2.1.1",
|
|
46
|
-
"marked": "^17.0.
|
|
47
|
-
"preact": "^10.
|
|
46
|
+
"marked": "^17.0.4",
|
|
47
|
+
"preact": "^10.29.0",
|
|
48
48
|
"prosemirror-commands": "^1.7.1",
|
|
49
49
|
"prosemirror-inputrules": "^1.5.1",
|
|
50
50
|
"prosemirror-keymap": "^1.2.3",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"tailwind-merge": "^3.5.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"astro": "^
|
|
60
|
+
"astro": "^6.0.2",
|
|
61
61
|
"typescript": "^5",
|
|
62
62
|
"vite": "^7.3.1",
|
|
63
63
|
"@aws-sdk/client-s3": "^3.0.0"
|
package/src/dev-middleware.ts
CHANGED
|
@@ -4,7 +4,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { scanCollections } from './collection-scanner'
|
|
6
6
|
import { getProjectRoot } from './config'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildMapPattern,
|
|
9
|
+
detectArrayPattern,
|
|
10
|
+
extractArrayElementProps,
|
|
11
|
+
handleAddArrayItem,
|
|
12
|
+
handleRemoveArrayItem,
|
|
13
|
+
parseInlineArrayName,
|
|
14
|
+
} from './handlers/array-ops'
|
|
8
15
|
import {
|
|
9
16
|
extractPropsFromSource,
|
|
10
17
|
findComponentInvocationLine,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useMemo } from 'preact/hooks'
|
|
2
1
|
import { signal } from '@preact/signals'
|
|
2
|
+
import { useMemo } from 'preact/hooks'
|
|
3
3
|
import { deleteMarkdownPage } from '../markdown-api'
|
|
4
4
|
import {
|
|
5
5
|
closeCollectionsBrowser,
|
|
@@ -144,66 +144,68 @@ export function CollectionsBrowser() {
|
|
|
144
144
|
)}
|
|
145
145
|
{entries.map((entry) => (
|
|
146
146
|
<div key={entry.slug} class="relative" data-cms-ui>
|
|
147
|
-
{confirmDeleteSlug.value === entry.slug
|
|
148
|
-
|
|
149
|
-
<div class="flex-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<button
|
|
153
|
-
type="button"
|
|
154
|
-
onClick={() => handleConfirmDelete(entry.slug, entry.sourcePath)}
|
|
155
|
-
disabled={deletingEntry.value === entry.slug}
|
|
156
|
-
class="px-3 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-cms-pill transition-colors disabled:opacity-50"
|
|
157
|
-
data-cms-ui
|
|
158
|
-
>
|
|
159
|
-
{deletingEntry.value === entry.slug ? 'Deleting...' : 'Delete'}
|
|
160
|
-
</button>
|
|
161
|
-
<button
|
|
162
|
-
type="button"
|
|
163
|
-
onClick={handleCancelDelete}
|
|
164
|
-
class="px-3 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/10 hover:bg-white/20 rounded-cms-pill transition-colors"
|
|
165
|
-
data-cms-ui
|
|
166
|
-
>
|
|
167
|
-
Cancel
|
|
168
|
-
</button>
|
|
169
|
-
</div>
|
|
170
|
-
) : (
|
|
171
|
-
<button
|
|
172
|
-
type="button"
|
|
173
|
-
onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
|
|
174
|
-
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
|
|
175
|
-
data-cms-ui
|
|
176
|
-
>
|
|
177
|
-
<div class="flex-1 min-w-0">
|
|
178
|
-
<div class={`font-medium truncate ${entry.draft ? 'text-white/40' : 'text-white'}`}>
|
|
179
|
-
{entry.title || entry.slug}
|
|
147
|
+
{confirmDeleteSlug.value === entry.slug
|
|
148
|
+
? (
|
|
149
|
+
<div class="flex items-center gap-2 px-4 py-3 bg-red-500/10 border border-red-500/20 rounded-cms-lg" data-cms-ui>
|
|
150
|
+
<div class="flex-1 min-w-0 text-sm text-white/70">
|
|
151
|
+
Delete "{entry.title || entry.slug}"?
|
|
180
152
|
</div>
|
|
181
|
-
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => handleConfirmDelete(entry.slug, entry.sourcePath)}
|
|
156
|
+
disabled={deletingEntry.value === entry.slug}
|
|
157
|
+
class="px-3 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-cms-pill transition-colors disabled:opacity-50"
|
|
158
|
+
data-cms-ui
|
|
159
|
+
>
|
|
160
|
+
{deletingEntry.value === entry.slug ? 'Deleting...' : 'Delete'}
|
|
161
|
+
</button>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={handleCancelDelete}
|
|
165
|
+
class="px-3 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/10 hover:bg-white/20 rounded-cms-pill transition-colors"
|
|
166
|
+
data-cms-ui
|
|
167
|
+
>
|
|
168
|
+
Cancel
|
|
169
|
+
</button>
|
|
182
170
|
</div>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
Draft
|
|
186
|
-
</span>
|
|
187
|
-
)}
|
|
171
|
+
)
|
|
172
|
+
: (
|
|
188
173
|
<button
|
|
189
174
|
type="button"
|
|
190
|
-
onClick={(
|
|
191
|
-
class="
|
|
192
|
-
title="Delete entry"
|
|
175
|
+
onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
|
|
176
|
+
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
|
|
193
177
|
data-cms-ui
|
|
194
178
|
>
|
|
195
|
-
<
|
|
179
|
+
<div class="flex-1 min-w-0">
|
|
180
|
+
<div class={`font-medium truncate ${entry.draft ? 'text-white/40' : 'text-white'}`}>
|
|
181
|
+
{entry.title || entry.slug}
|
|
182
|
+
</div>
|
|
183
|
+
{entry.title && <div class="text-white/30 text-xs truncate">{entry.slug}</div>}
|
|
184
|
+
</div>
|
|
185
|
+
{entry.draft && (
|
|
186
|
+
<span class="shrink-0 px-2 py-0.5 text-xs font-medium text-amber-400/80 bg-amber-400/10 rounded-full border border-amber-400/20">
|
|
187
|
+
Draft
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={(e) => handleDeleteClick(e, entry.slug)}
|
|
193
|
+
class="shrink-0 p-1 text-white/0 group-hover:text-white/30 hover:!text-red-400 rounded transition-colors"
|
|
194
|
+
title="Delete entry"
|
|
195
|
+
data-cms-ui
|
|
196
|
+
>
|
|
197
|
+
<TrashIcon />
|
|
198
|
+
</button>
|
|
199
|
+
<svg
|
|
200
|
+
class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
|
|
201
|
+
fill="none"
|
|
202
|
+
stroke="currentColor"
|
|
203
|
+
viewBox="0 0 24 24"
|
|
204
|
+
>
|
|
205
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
206
|
+
</svg>
|
|
196
207
|
</button>
|
|
197
|
-
|
|
198
|
-
class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
|
|
199
|
-
fill="none"
|
|
200
|
-
stroke="currentColor"
|
|
201
|
-
viewBox="0 0 24 24"
|
|
202
|
-
>
|
|
203
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
204
|
-
</svg>
|
|
205
|
-
</button>
|
|
206
|
-
)}
|
|
208
|
+
)}
|
|
207
209
|
</div>
|
|
208
210
|
))}
|
|
209
211
|
</div>
|
|
@@ -333,7 +335,12 @@ function BackArrowIcon() {
|
|
|
333
335
|
function TrashIcon() {
|
|
334
336
|
return (
|
|
335
337
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
336
|
-
<path
|
|
338
|
+
<path
|
|
339
|
+
stroke-linecap="round"
|
|
340
|
+
stroke-linejoin="round"
|
|
341
|
+
stroke-width="2"
|
|
342
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
343
|
+
/>
|
|
337
344
|
</svg>
|
|
338
345
|
)
|
|
339
346
|
}
|
|
@@ -152,7 +152,7 @@ function applyChanges(
|
|
|
152
152
|
return { newContent, appliedCount, failedChanges }
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
function applyImageChange(
|
|
155
|
+
export function applyImageChange(
|
|
156
156
|
content: string,
|
|
157
157
|
change: ChangePayload,
|
|
158
158
|
): { success: true; content: string } | { success: false; error: string } {
|
|
@@ -223,19 +223,15 @@ function applyImageChange(
|
|
|
223
223
|
|
|
224
224
|
// Verify we're in an img or Image component context before replacing
|
|
225
225
|
if (/<img\b/i.test(regionText) || /<Image\b/.test(regionText)) {
|
|
226
|
-
// Match src attribute with expression value: src={...} (handling balanced braces)
|
|
227
226
|
const exprMatch = findExpressionSrcAttribute(regionText)
|
|
228
227
|
if (exprMatch) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
+ `src="${escapedNewSrc}"`
|
|
237
|
-
+ newContent.slice(absoluteIndex + exprMatch.length)
|
|
238
|
-
replacedIndex = absoluteIndex
|
|
228
|
+
// Any expression-based src (variable, function call, template literal, etc.)
|
|
229
|
+
// cannot be safely replaced with a static string — refuse the edit.
|
|
230
|
+
const exprContent = regionText.slice(
|
|
231
|
+
exprMatch.index + regionText.slice(exprMatch.index).indexOf('{') + 1,
|
|
232
|
+
exprMatch.index + exprMatch.length - 1,
|
|
233
|
+
).trim()
|
|
234
|
+
return { success: false, error: `Image src uses a dynamic expression (src={${exprContent}}) — edit the data source directly` }
|
|
239
235
|
}
|
|
240
236
|
}
|
|
241
237
|
}
|
|
@@ -250,28 +246,42 @@ function applyImageChange(
|
|
|
250
246
|
const searchEnd = Math.min(newContent.length, replacedIndex + 300)
|
|
251
247
|
const region = newContent.slice(searchStart, searchEnd)
|
|
252
248
|
|
|
249
|
+
// Try string-literal alt first, then expression alt with balanced braces
|
|
250
|
+
let altIndex = -1
|
|
251
|
+
let altLength = 0
|
|
252
|
+
let altQuote = '"'
|
|
253
|
+
|
|
253
254
|
const altPatternDouble = /alt="[^"]*"/
|
|
254
255
|
const altPatternSingle = /alt='[^']*'/
|
|
255
|
-
// Also match expression-based alt: alt={...}
|
|
256
|
-
const altPatternExpr = /alt\s*=\s*\{[^}]*\}/
|
|
257
|
-
|
|
258
256
|
const altDoubleMatch = region.match(altPatternDouble)
|
|
259
257
|
const altSingleMatch = region.match(altPatternSingle)
|
|
260
|
-
const altExprMatch = region.match(altPatternExpr)
|
|
261
258
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
259
|
+
if (altDoubleMatch && altDoubleMatch.index !== undefined) {
|
|
260
|
+
altIndex = altDoubleMatch.index
|
|
261
|
+
altLength = altDoubleMatch[0].length
|
|
262
|
+
altQuote = '"'
|
|
263
|
+
} else if (altSingleMatch && altSingleMatch.index !== undefined) {
|
|
264
|
+
altIndex = altSingleMatch.index
|
|
265
|
+
altLength = altSingleMatch[0].length
|
|
266
|
+
altQuote = "'"
|
|
267
|
+
} else {
|
|
268
|
+
// Expression-based alt={...} — use balanced brace matching
|
|
269
|
+
const altExprMatch = findExpressionAltAttribute(region)
|
|
270
|
+
if (altExprMatch) {
|
|
271
|
+
altIndex = altExprMatch.index
|
|
272
|
+
altLength = altExprMatch.length
|
|
273
|
+
altQuote = '"'
|
|
274
|
+
}
|
|
275
|
+
}
|
|
265
276
|
|
|
266
|
-
if (
|
|
267
|
-
const altAbsoluteIndex = searchStart +
|
|
268
|
-
// Escape quotes in alt text matching the quote style used
|
|
277
|
+
if (altIndex >= 0) {
|
|
278
|
+
const altAbsoluteIndex = searchStart + altIndex
|
|
269
279
|
const escapedAlt = altQuote === '"'
|
|
270
280
|
? newAlt.replace(/"/g, '"')
|
|
271
281
|
: newAlt.replace(/'/g, ''')
|
|
272
282
|
newContent = newContent.slice(0, altAbsoluteIndex)
|
|
273
283
|
+ `alt=${altQuote}${escapedAlt}${altQuote}`
|
|
274
|
-
+ newContent.slice(altAbsoluteIndex +
|
|
284
|
+
+ newContent.slice(altAbsoluteIndex + altLength)
|
|
275
285
|
}
|
|
276
286
|
}
|
|
277
287
|
|
|
@@ -705,14 +715,12 @@ function resolveCmsPlaceholders(text: string, manifest: CmsManifest): string {
|
|
|
705
715
|
}
|
|
706
716
|
|
|
707
717
|
/**
|
|
708
|
-
* Find
|
|
709
|
-
* Handles balanced braces for nested expressions.
|
|
718
|
+
* Find an attribute with expression value (e.g., attr={variable}) using balanced brace matching.
|
|
710
719
|
* Returns the match with index and length, or null if not found.
|
|
711
720
|
*/
|
|
712
|
-
function
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
const match = text.match(srcExprStart)
|
|
721
|
+
function findExpressionAttribute(text: string, attr: string): { index: number; length: number } | null {
|
|
722
|
+
const exprStart = new RegExp(`${attr}\\s*=\\s*\\{`)
|
|
723
|
+
const match = text.match(exprStart)
|
|
716
724
|
if (!match || match.index === undefined) return null
|
|
717
725
|
|
|
718
726
|
// Find the matching closing brace (handle nesting)
|
|
@@ -733,6 +741,14 @@ function findExpressionSrcAttribute(text: string): { index: number; length: numb
|
|
|
733
741
|
}
|
|
734
742
|
}
|
|
735
743
|
|
|
744
|
+
export function findExpressionSrcAttribute(text: string): { index: number; length: number } | null {
|
|
745
|
+
return findExpressionAttribute(text, 'src')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function findExpressionAltAttribute(text: string): { index: number; length: number } | null {
|
|
749
|
+
return findExpressionAttribute(text, 'alt')
|
|
750
|
+
}
|
|
751
|
+
|
|
736
752
|
/**
|
|
737
753
|
* Extract visible text from an HTML string the way a browser would render it.
|
|
738
754
|
* Text nodes contribute their content, <br> elements become '\n',
|
|
@@ -339,18 +339,152 @@ export function indexFileContent(cached: CachedParsedFile, relFile: string): voi
|
|
|
339
339
|
visit(cached.ast)
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Resolve a .map() callback parameter back to the source array path.
|
|
344
|
+
*
|
|
345
|
+
* Given expression text like:
|
|
346
|
+
* "categories.map((cat) => (\n cat.images.map((img, i) => (\n "
|
|
347
|
+
* and a parameter name like "img", returns "categories[*].images" — the
|
|
348
|
+
* array path that the parameter iterates over.
|
|
349
|
+
*
|
|
350
|
+
* Supports chained .map() calls (nested loops).
|
|
351
|
+
*/
|
|
352
|
+
export function resolveMapChain(exprTexts: string[], paramName: string): string | null {
|
|
353
|
+
const fullText = exprTexts.join('')
|
|
354
|
+
|
|
355
|
+
// Find all .map() calls: <arrayExpr>.map((<param>, ...) =>
|
|
356
|
+
// Capture: [1] = array expression, [2] = first callback parameter
|
|
357
|
+
const mapPattern = /([\w.[\]]+)\.map\(\s*\(\s*(\w+)/g
|
|
358
|
+
const maps: Array<{ arrayExpr: string; param: string }> = []
|
|
359
|
+
let match: RegExpExecArray | null
|
|
360
|
+
while ((match = mapPattern.exec(fullText)) !== null) {
|
|
361
|
+
maps.push({ arrayExpr: match[1]!, param: match[2]! })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (maps.length === 0) return null
|
|
365
|
+
|
|
366
|
+
// Find which .map() provides our paramName
|
|
367
|
+
const directMap = maps.find(m => m.param === paramName)
|
|
368
|
+
if (!directMap) return null
|
|
369
|
+
|
|
370
|
+
// Resolve the array expression by substituting outer .map() params
|
|
371
|
+
// e.g., "cat.images" where "cat" comes from "categories.map((cat) => ...)"
|
|
372
|
+
let arrayPath = directMap.arrayExpr
|
|
373
|
+
for (const outerMap of maps) {
|
|
374
|
+
if (outerMap === directMap) continue
|
|
375
|
+
// If arrayPath starts with an outer param name, substitute it
|
|
376
|
+
// e.g., "cat.images" and cat comes from "categories" → "categories[*].images"
|
|
377
|
+
if (arrayPath === outerMap.param || arrayPath.startsWith(outerMap.param + '.')) {
|
|
378
|
+
const suffix = arrayPath.slice(outerMap.param.length) // ".images" or ""
|
|
379
|
+
const resolvedOuter = resolveMapChain(exprTexts, outerMap.param)
|
|
380
|
+
if (resolvedOuter) {
|
|
381
|
+
arrayPath = resolvedOuter + '[*]' + suffix
|
|
382
|
+
} else {
|
|
383
|
+
arrayPath = outerMap.arrayExpr + '[*]' + suffix
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return arrayPath
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Index images from an expression-based src={variable} by tracing
|
|
393
|
+
* the variable through .map() calls back to the data source array,
|
|
394
|
+
* then adding each array element to the image search index.
|
|
395
|
+
*/
|
|
396
|
+
function indexExpressionImageSrc(
|
|
397
|
+
exprValue: string,
|
|
398
|
+
parentExpression: AstroNode,
|
|
399
|
+
cached: CachedParsedFile,
|
|
400
|
+
relFile: string,
|
|
401
|
+
): void {
|
|
402
|
+
// Collect text content from the expression node (contains the .map() calls)
|
|
403
|
+
const exprTexts: string[] = []
|
|
404
|
+
if ('children' in parentExpression && Array.isArray(parentExpression.children)) {
|
|
405
|
+
for (const child of parentExpression.children) {
|
|
406
|
+
if (child.type === 'text' && (child as TextNode).value) {
|
|
407
|
+
exprTexts.push((child as TextNode).value)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (exprTexts.length === 0) return
|
|
413
|
+
|
|
414
|
+
// Resolve the .map() chain to find the source array path
|
|
415
|
+
const arrayPath = resolveMapChain(exprTexts, exprValue)
|
|
416
|
+
if (!arrayPath) return
|
|
417
|
+
|
|
418
|
+
for (const def of cached.variableDefinitions) {
|
|
419
|
+
const defPath = buildDefinitionPath(def)
|
|
420
|
+
// Match definitions that are direct children of the array
|
|
421
|
+
// e.g., for "images" match "images[0]", "images[1]"
|
|
422
|
+
// e.g., for "categories[*].images" match "categories[0].images[0]", etc.
|
|
423
|
+
if (isChildOfArray(defPath, arrayPath)) {
|
|
424
|
+
const snippet = cached.lines[def.line - 1]?.trim() || ''
|
|
425
|
+
addToImageSearchIndex({
|
|
426
|
+
file: relFile,
|
|
427
|
+
line: def.line,
|
|
428
|
+
snippet,
|
|
429
|
+
src: def.value,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Check if a definition path is a direct element of the given array path.
|
|
437
|
+
* Converts the arrayPath pattern (with optional [*] wildcards) into a regex
|
|
438
|
+
* that matches concrete indices.
|
|
439
|
+
*
|
|
440
|
+
* e.g., "images[0]" is a child of "images"
|
|
441
|
+
* e.g., "categories[0].images[1]" is a child of "categories[*].images"
|
|
442
|
+
* e.g., "categories[0].images[1].url" is NOT a child (too deep)
|
|
443
|
+
*/
|
|
444
|
+
export function isChildOfArray(defPath: string, arrayPath: string): boolean {
|
|
445
|
+
// Split arrayPath on [*] to get segments, then build a regex
|
|
446
|
+
// "categories[*].images" → ["categories", ".images"] → /^categories\[\d+\]\.images\[\d+\]$/
|
|
447
|
+
const segments = arrayPath.split('[*]')
|
|
448
|
+
let regexStr = '^'
|
|
449
|
+
for (let i = 0; i < segments.length; i++) {
|
|
450
|
+
regexStr += escapeRegex(segments[i]!)
|
|
451
|
+
if (i < segments.length - 1) {
|
|
452
|
+
regexStr += '\\[\\d+\\]'
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
regexStr += '\\[\\d+\\]$'
|
|
456
|
+
return new RegExp(regexStr).test(defPath)
|
|
457
|
+
}
|
|
458
|
+
|
|
342
459
|
/**
|
|
343
460
|
* Index all images from a parsed file
|
|
344
461
|
*/
|
|
345
462
|
export function indexFileImages(cached: CachedParsedFile, relFile: string): void {
|
|
346
463
|
// For Astro files, use AST
|
|
347
464
|
if (relFile.endsWith('.astro')) {
|
|
348
|
-
function visit(node: AstroNode) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
465
|
+
function visit(node: AstroNode, parentExpression: AstroNode | null) {
|
|
466
|
+
// Track the nearest ancestor expression node (contains .map() context)
|
|
467
|
+
const currentExpr = node.type === 'expression' ? node : parentExpression
|
|
468
|
+
|
|
469
|
+
if (node.type === 'element' || node.type === 'component') {
|
|
470
|
+
const elemNode = node as ElementNode | ComponentNode
|
|
471
|
+
const isImg = node.type === 'element' && elemNode.name.toLowerCase() === 'img'
|
|
472
|
+
const isImageComponent = node.type === 'component' && elemNode.name === 'Image'
|
|
473
|
+
|
|
474
|
+
if (isImg || isImageComponent) {
|
|
352
475
|
for (const attr of elemNode.attributes) {
|
|
353
|
-
if (attr.type
|
|
476
|
+
if (attr.type !== 'attribute' || attr.name !== 'src' || !attr.value) continue
|
|
477
|
+
|
|
478
|
+
if ((attr as any).kind === 'expression' && currentExpr) {
|
|
479
|
+
// Expression src={variable} — trace through .map() to data source
|
|
480
|
+
indexExpressionImageSrc(
|
|
481
|
+
attr.value,
|
|
482
|
+
currentExpr,
|
|
483
|
+
cached,
|
|
484
|
+
relFile,
|
|
485
|
+
)
|
|
486
|
+
} else if ((attr as any).kind !== 'expression') {
|
|
487
|
+
// Static src="..." — index directly
|
|
354
488
|
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
355
489
|
const snippet = extractImageSnippet(cached.lines, srcLine - 1)
|
|
356
490
|
addToImageSearchIndex({
|
|
@@ -364,31 +498,13 @@ export function indexFileImages(cached: CachedParsedFile, relFile: string): void
|
|
|
364
498
|
}
|
|
365
499
|
}
|
|
366
500
|
|
|
367
|
-
// Also index component nodes with src attributes (e.g., <Image src="..." />)
|
|
368
|
-
// This captures image component usages where the actual src is defined
|
|
369
|
-
if (node.type === 'component') {
|
|
370
|
-
const compNode = node as ComponentNode
|
|
371
|
-
for (const attr of compNode.attributes) {
|
|
372
|
-
if (attr.type === 'attribute' && attr.name === 'src' && attr.value) {
|
|
373
|
-
const srcLine = attr.position?.start.line ?? compNode.position?.start.line ?? 0
|
|
374
|
-
const snippet = extractImageSnippet(cached.lines, srcLine - 1)
|
|
375
|
-
addToImageSearchIndex({
|
|
376
|
-
file: relFile,
|
|
377
|
-
line: srcLine,
|
|
378
|
-
snippet,
|
|
379
|
-
src: attr.value,
|
|
380
|
-
})
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
501
|
if ('children' in node && Array.isArray(node.children)) {
|
|
386
502
|
for (const child of node.children) {
|
|
387
|
-
visit(child)
|
|
503
|
+
visit(child, currentExpr)
|
|
388
504
|
}
|
|
389
505
|
}
|
|
390
506
|
}
|
|
391
|
-
visit(cached.ast)
|
|
507
|
+
visit(cached.ast, null)
|
|
392
508
|
} else {
|
|
393
509
|
// For tsx/jsx, use regex
|
|
394
510
|
const srcPatterns = [/src="([^"]+)"/g, /src='([^']+)'/g]
|
package/src/vite-plugin.ts
CHANGED
|
@@ -47,14 +47,14 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
|
|
|
47
47
|
// processes them. We use prependListener so our handler runs first.
|
|
48
48
|
const watcher = server.watcher
|
|
49
49
|
const origEmit = watcher.emit.bind(watcher)
|
|
50
|
-
watcher.emit =
|
|
50
|
+
watcher.emit = ((event: string, filePath: string, ...args: any[]) => {
|
|
51
51
|
if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
|
|
52
52
|
expectedDeletions.delete(filePath)
|
|
53
53
|
// Swallow the event — don't let Vite/Astro see it
|
|
54
54
|
return true
|
|
55
55
|
}
|
|
56
56
|
return origEmit(event, filePath, ...args)
|
|
57
|
-
} as typeof watcher.emit
|
|
57
|
+
}) as typeof watcher.emit
|
|
58
58
|
},
|
|
59
59
|
}
|
|
60
60
|
|