@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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.13.3",
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": "^2.13.1",
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.1",
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.3",
47
- "preact": "^10.28.4",
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": "^5.17",
60
+ "astro": "^6.0.2",
61
61
  "typescript": "^5",
62
62
  "vite": "^7.3.1",
63
63
  "@aws-sdk/client-s3": "^3.0.0"
@@ -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 { buildMapPattern, detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem, parseInlineArrayName } from './handlers/array-ops'
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
- <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>
149
- <div class="flex-1 min-w-0 text-sm text-white/70">
150
- Delete "{entry.title || entry.slug}"?
151
- </div>
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
- {entry.title && <div class="text-white/30 text-xs truncate">{entry.slug}</div>}
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
- {entry.draft && (
184
- <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">
185
- Draft
186
- </span>
187
- )}
171
+ )
172
+ : (
188
173
  <button
189
174
  type="button"
190
- onClick={(e) => handleDeleteClick(e, entry.slug)}
191
- class="shrink-0 p-1 text-white/0 group-hover:text-white/30 hover:!text-red-400 rounded transition-colors"
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
- <TrashIcon />
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
- <svg
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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
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
- const regionOffset = regionStart > 0
230
- ? lines.slice(0, regionStart).join('\n').length + 1
231
- : 0
232
- const absoluteIndex = regionOffset + exprMatch.index
233
-
234
- const escapedNewSrc = escapeReplacement(newSrc)
235
- newContent = newContent.slice(0, absoluteIndex)
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
- // Pick the first match found (string literals preferred over expressions)
263
- const altMatch = altDoubleMatch ?? altSingleMatch ?? altExprMatch
264
- const altQuote = altDoubleMatch ? '"' : altSingleMatch ? "'" : '"'
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 (altMatch && altMatch.index !== undefined) {
267
- const altAbsoluteIndex = searchStart + altMatch.index
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, '&quot;')
271
281
  : newAlt.replace(/'/g, '&#39;')
272
282
  newContent = newContent.slice(0, altAbsoluteIndex)
273
283
  + `alt=${altQuote}${escapedAlt}${altQuote}`
274
- + newContent.slice(altAbsoluteIndex + altMatch[0].length)
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 a src attribute with expression value (e.g., src={variable}) in text.
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 findExpressionSrcAttribute(text: string): { index: number; length: number } | null {
713
- // Find 'src=' followed by '{'
714
- const srcExprStart = /src\s*=\s*\{/
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
- if (node.type === 'element') {
350
- const elemNode = node as ElementNode
351
- if (elemNode.name.toLowerCase() === 'img') {
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 === 'attribute' && attr.name === 'src' && attr.value) {
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]
@@ -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 = function (event: string, filePath: string, ...args: any[]) {
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