@nuasite/cms 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.js
CHANGED
|
@@ -381,7 +381,7 @@ function PS(t, e) {
|
|
|
381
381
|
function LS(t, e) {
|
|
382
382
|
return typeof e == "function" ? e(t) : e;
|
|
383
383
|
}
|
|
384
|
-
const o3 = "0.
|
|
384
|
+
const o3 = "0.36.0", a3 = o3, nt = {
|
|
385
385
|
/** Highlight overlay for hovered elements */
|
|
386
386
|
HIGHLIGHT: 2147483644,
|
|
387
387
|
/** Hover outline for elements/components */
|
package/package.json
CHANGED
package/src/astro-image-paths.ts
CHANGED
|
@@ -47,22 +47,41 @@ export async function pickAstroImageTarget(args: {
|
|
|
47
47
|
return { absPath: baseAbs, relPath: `./${baseName}` }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
|
|
50
|
+
const candidateAbs = await pickHashedSibling(entryDir, baseName, args.compareBuffer)
|
|
51
|
+
return { absPath: candidateAbs, relPath: `./${path.basename(candidateAbs)}` }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pick an absolute path in `dir` for `filename`; if a file with the same name already
|
|
56
|
+
* exists, content-hash-suffix it. Reuses an existing file with identical bytes.
|
|
57
|
+
*
|
|
58
|
+
* `filename` may come from a URL pathname or other untrusted source — directory
|
|
59
|
+
* components are stripped via `path.basename` so a `../` segment can't escape `dir`.
|
|
60
|
+
*/
|
|
61
|
+
export async function pickSiblingTarget(dir: string, filename: string, buf: Buffer): Promise<string> {
|
|
62
|
+
const safe = path.basename(filename)
|
|
63
|
+
if (!safe || safe === '.' || safe === '..') {
|
|
64
|
+
throw new Error(`Invalid filename: ${filename}`)
|
|
65
|
+
}
|
|
66
|
+
const baseAbs = path.join(dir, safe)
|
|
67
|
+
if (await isFreeOrMatching(baseAbs, buf)) return baseAbs
|
|
68
|
+
return pickHashedSibling(dir, safe, buf)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function pickHashedSibling(dir: string, baseName: string, buf: Buffer): Promise<string> {
|
|
72
|
+
const hash = shortContentHash(buf)
|
|
73
|
+
const ext = path.extname(baseName)
|
|
52
74
|
const stem = path.basename(baseName, ext)
|
|
53
75
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
54
76
|
const suffix = attempt === 0 ? hash : `${hash}-${attempt}`
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
if (await isFreeOrMatching(candidateAbs, args.compareBuffer)) {
|
|
58
|
-
return { absPath: candidateAbs, relPath: `./${candidateName}` }
|
|
59
|
-
}
|
|
77
|
+
const candidateAbs = path.join(dir, `${stem}-${suffix}${ext}`)
|
|
78
|
+
if (await isFreeOrMatching(candidateAbs, buf)) return candidateAbs
|
|
60
79
|
}
|
|
61
|
-
throw new Error(`Could not pick a unique filename for ${
|
|
80
|
+
throw new Error(`Could not pick a unique filename for ${baseName} in ${dir}`)
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
/** True if the slot is empty, or holds a file with identical bytes to `compareBuffer`. */
|
|
65
|
-
async function isFreeOrMatching(absPath: string, compareBuffer: Buffer): Promise<boolean> {
|
|
84
|
+
export async function isFreeOrMatching(absPath: string, compareBuffer: Buffer): Promise<boolean> {
|
|
66
85
|
try {
|
|
67
86
|
const stat = await fs.stat(absPath)
|
|
68
87
|
if (stat.size !== compareBuffer.length) return false
|
|
@@ -2,12 +2,13 @@ import { NodeType, parse as parseHtml } from 'node-html-parser'
|
|
|
2
2
|
import fs from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
5
|
+
import { pickSiblingTarget } from '../astro-image-paths'
|
|
5
6
|
import { getProjectRoot } from '../config'
|
|
6
7
|
import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
|
|
7
8
|
import type { ManifestWriter } from '../manifest-writer'
|
|
8
9
|
import { extractAstroImageOriginalUrl } from '../source-finder/snippet-utils'
|
|
9
10
|
import type { CmsManifest, ManifestEntry } from '../types'
|
|
10
|
-
import { acquireFileLock, escapeRegex, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
|
|
11
|
+
import { acquireFileLock, escapeRegex, escapeReplacement, normalizePagePath, relativeImportPath, resolveAndValidatePath } from '../utils'
|
|
11
12
|
|
|
12
13
|
export interface SaveBatchResponse {
|
|
13
14
|
updated: number
|
|
@@ -56,16 +57,23 @@ export async function handleUpdate(
|
|
|
56
57
|
try {
|
|
57
58
|
const currentContent = await fs.readFile(fullPath, 'utf-8')
|
|
58
59
|
|
|
59
|
-
const { newContent, appliedCount, failedChanges } = applyChanges(
|
|
60
|
+
const { newContent, appliedCount, failedChanges, fileOps } = await applyChanges(
|
|
60
61
|
currentContent,
|
|
61
62
|
fileChanges,
|
|
62
63
|
manifest,
|
|
64
|
+
fullPath,
|
|
65
|
+
meta.url,
|
|
63
66
|
)
|
|
64
67
|
if (failedChanges.length > 0) {
|
|
65
68
|
errors.push(...failedChanges)
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
if (appliedCount > 0 && newContent !== currentContent) {
|
|
72
|
+
// Write assets first so the source file never points at missing files.
|
|
73
|
+
for (const op of fileOps) {
|
|
74
|
+
await fs.mkdir(path.dirname(op.target), { recursive: true })
|
|
75
|
+
await fs.writeFile(op.target, op.bytes)
|
|
76
|
+
}
|
|
69
77
|
await fs.writeFile(fullPath, newContent, 'utf-8')
|
|
70
78
|
updated += appliedCount
|
|
71
79
|
}
|
|
@@ -86,18 +94,28 @@ export async function handleUpdate(
|
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
/** Asset write that must land alongside the source rewrite (assets first, then source). */
|
|
98
|
+
interface PendingFileOp {
|
|
99
|
+
target: string
|
|
100
|
+
bytes: Buffer
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function applyChanges(
|
|
90
104
|
content: string,
|
|
91
105
|
changes: ChangePayload[],
|
|
92
106
|
manifest: CmsManifest,
|
|
93
|
-
|
|
107
|
+
absFilePath: string,
|
|
108
|
+
originUrl: string,
|
|
109
|
+
): Promise<{
|
|
94
110
|
newContent: string
|
|
95
111
|
appliedCount: number
|
|
96
112
|
failedChanges: Array<{ cmsId: string; error: string }>
|
|
97
|
-
|
|
113
|
+
fileOps: PendingFileOp[]
|
|
114
|
+
}> {
|
|
98
115
|
let newContent = content
|
|
99
116
|
let appliedCount = 0
|
|
100
117
|
const failedChanges: Array<{ cmsId: string; error: string }> = []
|
|
118
|
+
const fileOps: PendingFileOp[] = []
|
|
101
119
|
|
|
102
120
|
// Sort changes by source line descending to prevent offset shifts
|
|
103
121
|
const sortedChanges = [...changes].sort(
|
|
@@ -107,9 +125,10 @@ function applyChanges(
|
|
|
107
125
|
for (const change of sortedChanges) {
|
|
108
126
|
// Handle image changes
|
|
109
127
|
if (change.imageChange) {
|
|
110
|
-
const result = applyImageChange(newContent, change)
|
|
128
|
+
const result = await applyImageChange(newContent, change, absFilePath, originUrl)
|
|
111
129
|
if (result.success) {
|
|
112
130
|
newContent = result.content
|
|
131
|
+
if (result.fileOp) fileOps.push(result.fileOp)
|
|
113
132
|
appliedCount++
|
|
114
133
|
} else {
|
|
115
134
|
failedChanges.push({ cmsId: change.cmsId, error: result.error })
|
|
@@ -150,13 +169,15 @@ function applyChanges(
|
|
|
150
169
|
}
|
|
151
170
|
}
|
|
152
171
|
|
|
153
|
-
return { newContent, appliedCount, failedChanges }
|
|
172
|
+
return { newContent, appliedCount, failedChanges, fileOps }
|
|
154
173
|
}
|
|
155
174
|
|
|
156
|
-
export function applyImageChange(
|
|
175
|
+
export async function applyImageChange(
|
|
157
176
|
content: string,
|
|
158
177
|
change: ChangePayload,
|
|
159
|
-
|
|
178
|
+
absFilePath?: string,
|
|
179
|
+
originUrl?: string,
|
|
180
|
+
): Promise<{ success: true; content: string; fileOp?: PendingFileOp } | { success: false; error: string }> {
|
|
160
181
|
const { newSrc, newAlt } = change.imageChange!
|
|
161
182
|
const originalSrc = change.originalValue
|
|
162
183
|
|
|
@@ -278,6 +299,7 @@ export function applyImageChange(
|
|
|
278
299
|
|
|
279
300
|
// Fallback: if literal src not found, try to find an expression-based src attribute
|
|
280
301
|
// near the source line (handles src={variable}, src={obj.prop}, etc.)
|
|
302
|
+
let pendingFileOp: PendingFileOp | undefined
|
|
281
303
|
if (replacedIndex < 0 && change.sourceLine > 0) {
|
|
282
304
|
const lines = newContent.split('\n')
|
|
283
305
|
const targetLineIdx = change.sourceLine - 1
|
|
@@ -292,13 +314,31 @@ export function applyImageChange(
|
|
|
292
314
|
if (/<img\b/i.test(regionText) || /<Image\b/.test(regionText)) {
|
|
293
315
|
const exprMatch = findExpressionSrcAttribute(regionText)
|
|
294
316
|
if (exprMatch) {
|
|
295
|
-
// Any expression-based src (variable, function call, template literal, etc.)
|
|
296
|
-
// cannot be safely replaced with a static string — refuse the edit.
|
|
297
317
|
const exprContent = regionText.slice(
|
|
298
318
|
exprMatch.index + regionText.slice(exprMatch.index).indexOf('{') + 1,
|
|
299
319
|
exprMatch.index + exprMatch.length - 1,
|
|
300
320
|
).trim()
|
|
301
|
-
|
|
321
|
+
|
|
322
|
+
// `<Image src={importedAsset}>` where `importedAsset` is a frontmatter asset
|
|
323
|
+
// import: prefer rewriting the import target so Astro's asset pipeline still
|
|
324
|
+
// processes the new image. Falls back to inline JSX replacement when the new
|
|
325
|
+
// src can't be resolved on disk (e.g. external URLs, non-local media adapters).
|
|
326
|
+
const importInfo = /^\w+$/.test(exprContent) ? findFrontmatterAssetImport(content, exprContent) : null
|
|
327
|
+
if (!importInfo) {
|
|
328
|
+
return { success: false, error: `Image src uses a dynamic expression (src={${exprContent}}) — edit the data source directly` }
|
|
329
|
+
}
|
|
330
|
+
const rewrite = absFilePath
|
|
331
|
+
? await tryRewriteAssetImport(content, importInfo, newSrc, absFilePath, originUrl)
|
|
332
|
+
: null
|
|
333
|
+
if (rewrite) {
|
|
334
|
+
newContent = rewrite.content
|
|
335
|
+
pendingFileOp = rewrite.fileOp
|
|
336
|
+
replacedIndex = rewrite.importSourceIndex
|
|
337
|
+
} else {
|
|
338
|
+
const literalResult = inlineJsxLiteralReplace(newContent, lines, regionStart, exprMatch, newSrc)
|
|
339
|
+
newContent = literalResult.content
|
|
340
|
+
replacedIndex = literalResult.replacedIndex
|
|
341
|
+
}
|
|
302
342
|
}
|
|
303
343
|
}
|
|
304
344
|
}
|
|
@@ -352,7 +392,159 @@ export function applyImageChange(
|
|
|
352
392
|
}
|
|
353
393
|
}
|
|
354
394
|
|
|
355
|
-
return { success: true, content: newContent }
|
|
395
|
+
return { success: true, content: newContent, fileOp: pendingFileOp }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface FrontmatterAssetImport {
|
|
399
|
+
/** Local binding name (e.g., `hero`). */
|
|
400
|
+
localName: string
|
|
401
|
+
/** Import source as written in the frontmatter (e.g., `'../assets/hero.png'`). */
|
|
402
|
+
source: string
|
|
403
|
+
/** Character offset of `source` (without quotes) in `content`. */
|
|
404
|
+
sourceStart: number
|
|
405
|
+
/** Character offset just past the `source` string (without quotes) in `content`. */
|
|
406
|
+
sourceEnd: number
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const ASSET_IMPORT_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$/i
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Locate the frontmatter `import varName from '<asset-path>'` statement that binds
|
|
413
|
+
* `varName` to a relative image asset. Returns the binding's source-string position
|
|
414
|
+
* so callers can rewrite just the path without re-tokenizing the import.
|
|
415
|
+
*/
|
|
416
|
+
function findFrontmatterAssetImport(content: string, varName: string): FrontmatterAssetImport | null {
|
|
417
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
418
|
+
if (!fmMatch) return null
|
|
419
|
+
const fmStart = fmMatch[0].indexOf(fmMatch[1]!)
|
|
420
|
+
const importRe = /^\s*import\s+(?!type\b)([\s\S]+?)\s+from\s+(['"])([^'"]+)\2/gm
|
|
421
|
+
let m: RegExpExecArray | null
|
|
422
|
+
while ((m = importRe.exec(fmMatch[1]!)) !== null) {
|
|
423
|
+
const source = m[3]!
|
|
424
|
+
if (!source.startsWith('.') || !ASSET_IMPORT_EXT_RE.test(source)) continue
|
|
425
|
+
// Skip per-binding `import { type X } from '...'` — those are erased at compile time.
|
|
426
|
+
const tokens = m[1]!.replace(/[{}]/g, ',').split(',').map(s => s.trim()).filter(t => t && !t.startsWith('type '))
|
|
427
|
+
const matches = tokens.some(tok => {
|
|
428
|
+
const aliasMatch = tok.match(/^\S+\s+as\s+(\S+)$/)
|
|
429
|
+
return (aliasMatch ? aliasMatch[1]! : tok) === varName
|
|
430
|
+
})
|
|
431
|
+
if (!matches) continue
|
|
432
|
+
// Compute absolute position of the path string (between the quote chars).
|
|
433
|
+
const matchStart = fmStart + m.index
|
|
434
|
+
const sourceStart = matchStart + m[0]!.indexOf(m[2]!) + 1
|
|
435
|
+
return { localName: varName, source, sourceStart, sourceEnd: sourceStart + source.length }
|
|
436
|
+
}
|
|
437
|
+
return null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Pure literal swap of `src={var}` → `src="<newSrc>"`. The fallback when import-rewrite
|
|
442
|
+
* isn't possible (e.g. the new src can't be read from disk).
|
|
443
|
+
*/
|
|
444
|
+
function inlineJsxLiteralReplace(
|
|
445
|
+
content: string,
|
|
446
|
+
lines: string[],
|
|
447
|
+
regionStart: number,
|
|
448
|
+
exprMatch: { index: number; length: number },
|
|
449
|
+
newSrc: string,
|
|
450
|
+
): { content: string; replacedIndex: number } {
|
|
451
|
+
let regionStartOffset = 0
|
|
452
|
+
for (let i = 0; i < regionStart; i++) regionStartOffset += lines[i]!.length + 1
|
|
453
|
+
const absIndex = regionStartOffset + exprMatch.index
|
|
454
|
+
return {
|
|
455
|
+
content: content.slice(0, absIndex) + `src="${escapeReplacement(newSrc)}"` + content.slice(absIndex + exprMatch.length),
|
|
456
|
+
replacedIndex: absIndex,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Rewrite the frontmatter import target so Astro's asset pipeline picks up the new image,
|
|
462
|
+
* and emit a paired file write for the bytes. Returns null only when the new src can't
|
|
463
|
+
* be resolved at all — caller falls back to inline JSX.
|
|
464
|
+
*/
|
|
465
|
+
async function tryRewriteAssetImport(
|
|
466
|
+
content: string,
|
|
467
|
+
importInfo: FrontmatterAssetImport,
|
|
468
|
+
newSrc: string,
|
|
469
|
+
absFilePath: string,
|
|
470
|
+
originUrl?: string,
|
|
471
|
+
): Promise<{ content: string; fileOp: PendingFileOp; importSourceIndex: number } | null> {
|
|
472
|
+
const resolved = await resolveNewSrcBytes(newSrc, originUrl)
|
|
473
|
+
if (!resolved) return null
|
|
474
|
+
|
|
475
|
+
const originalAssetAbs = path.resolve(path.dirname(absFilePath), importInfo.source)
|
|
476
|
+
const targetAbs = await pickSiblingTarget(path.dirname(originalAssetAbs), resolved.filename, resolved.bytes)
|
|
477
|
+
|
|
478
|
+
const newRelImport = relativeImportPath(absFilePath, targetAbs)
|
|
479
|
+
const newContent = content.slice(0, importInfo.sourceStart) + newRelImport + content.slice(importInfo.sourceEnd)
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
content: newContent,
|
|
483
|
+
fileOp: { target: targetAbs, bytes: resolved.bytes },
|
|
484
|
+
importSourceIndex: importInfo.sourceStart,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Resolve a new image src to bytes. Tries (in order): the local on-disk location matching
|
|
490
|
+
* the path's prefix (`/src/...` → project, `/...` → public/), then an HTTP fetch as a
|
|
491
|
+
* universal fallback for external URLs and remote media adapters.
|
|
492
|
+
*/
|
|
493
|
+
async function resolveNewSrcBytes(
|
|
494
|
+
newSrc: string,
|
|
495
|
+
originUrl: string | undefined,
|
|
496
|
+
): Promise<{ bytes: Buffer; filename: string } | null> {
|
|
497
|
+
const filenameFromPath = (p: string) => path.basename(p.split('?')[0] ?? p)
|
|
498
|
+
|
|
499
|
+
const diskPath = newSrc.startsWith('/src/')
|
|
500
|
+
? path.join(getProjectRoot(), newSrc.slice(1))
|
|
501
|
+
: newSrc.startsWith('/') && !newSrc.startsWith('//')
|
|
502
|
+
? path.join(getProjectRoot(), 'public', newSrc.replace(/^\/+/, ''))
|
|
503
|
+
: null
|
|
504
|
+
if (diskPath) {
|
|
505
|
+
try {
|
|
506
|
+
return { bytes: await fs.readFile(diskPath), filename: filenameFromPath(newSrc) }
|
|
507
|
+
} catch {
|
|
508
|
+
// Fall through to HTTP fetch
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const isAbsolute = /^https?:\/\//.test(newSrc)
|
|
514
|
+
if (!isAbsolute && !originUrl) return null
|
|
515
|
+
const fetchUrl = isAbsolute ? newSrc : new URL(newSrc, originUrl).toString()
|
|
516
|
+
const res = await fetch(fetchUrl, { signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS) })
|
|
517
|
+
if (!res.ok) return null
|
|
518
|
+
// Cap the response so a malicious or misbehaving remote can't OOM the dev server.
|
|
519
|
+
const bytes = await readBoundedBody(res, REMOTE_FETCH_MAX_BYTES)
|
|
520
|
+
if (!bytes) return null
|
|
521
|
+
return { bytes, filename: filenameFromPath(new URL(fetchUrl).pathname) }
|
|
522
|
+
} catch {
|
|
523
|
+
return null
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const REMOTE_FETCH_TIMEOUT_MS = 15_000
|
|
528
|
+
const REMOTE_FETCH_MAX_BYTES = 50 * 1024 * 1024
|
|
529
|
+
|
|
530
|
+
async function readBoundedBody(res: Response, maxBytes: number): Promise<Buffer | null> {
|
|
531
|
+
const declared = Number(res.headers.get('content-length'))
|
|
532
|
+
if (declared > maxBytes) return null
|
|
533
|
+
if (!res.body) return null
|
|
534
|
+
const reader = res.body.getReader()
|
|
535
|
+
const chunks: Uint8Array[] = []
|
|
536
|
+
let total = 0
|
|
537
|
+
while (true) {
|
|
538
|
+
const { done, value } = await reader.read()
|
|
539
|
+
if (done) break
|
|
540
|
+
total += value.byteLength
|
|
541
|
+
if (total > maxBytes) {
|
|
542
|
+
await reader.cancel()
|
|
543
|
+
return null
|
|
544
|
+
}
|
|
545
|
+
chunks.push(value)
|
|
546
|
+
}
|
|
547
|
+
return Buffer.concat(chunks, total)
|
|
356
548
|
}
|
|
357
549
|
|
|
358
550
|
function applyColorChange(
|
|
@@ -881,6 +1073,11 @@ export function findExpressionAltAttribute(text: string): { index: number; lengt
|
|
|
881
1073
|
return findExpressionAttribute(text, 'alt')
|
|
882
1074
|
}
|
|
883
1075
|
|
|
1076
|
+
/** True when `varName` is bound by a frontmatter `import ... from '<relative-asset-path>'`. */
|
|
1077
|
+
export function isFrontmatterAssetImport(content: string, varName: string): boolean {
|
|
1078
|
+
return findFrontmatterAssetImport(content, varName) !== null
|
|
1079
|
+
}
|
|
1080
|
+
|
|
884
1081
|
/**
|
|
885
1082
|
* Extract visible text from an HTML string the way a browser would render it.
|
|
886
1083
|
* Text nodes contribute their content, <br> elements become '\n',
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
setCollectionTextIndex,
|
|
22
22
|
setSearchIndexInitialized,
|
|
23
23
|
} from './cache'
|
|
24
|
-
import { extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
|
|
24
|
+
import { extractAstroImageOriginalUrl, extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
|
|
25
25
|
import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
|
|
26
26
|
|
|
27
27
|
/** Collection data files live under this path — used to prefer them over templates */
|
|
@@ -498,6 +498,21 @@ export function resolveMapChain(exprTexts: string[], paramName: string): string
|
|
|
498
498
|
return arrayPath
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Build a map of locally-imported asset bindings (e.g. `import hero from './x.png'`)
|
|
503
|
+
* to their absolute on-disk paths. Only relative imports with an image extension are
|
|
504
|
+
* included — these are the bindings that can appear as `<Image src={hero} />`.
|
|
505
|
+
*/
|
|
506
|
+
function buildImportedAssetMap(imports: CachedParsedFile['imports'], relFile: string): Map<string, string> {
|
|
507
|
+
const map = new Map<string, string>()
|
|
508
|
+
const fromDir = path.dirname(path.join(getProjectRoot(), relFile))
|
|
509
|
+
for (const imp of imports) {
|
|
510
|
+
if (!imp.source.startsWith('.') || !IMAGE_EXTENSIONS.test(imp.source)) continue
|
|
511
|
+
map.set(imp.localName, path.resolve(fromDir, imp.source))
|
|
512
|
+
}
|
|
513
|
+
return map
|
|
514
|
+
}
|
|
515
|
+
|
|
501
516
|
/**
|
|
502
517
|
* Index images from an expression-based src={variable} by tracing
|
|
503
518
|
* the variable through .map() calls back to the data source array,
|
|
@@ -572,6 +587,12 @@ export function isChildOfArray(defPath: string, arrayPath: string): boolean {
|
|
|
572
587
|
export function indexFileImages(cached: CachedParsedFile, relFile: string): void {
|
|
573
588
|
// For Astro files, use AST
|
|
574
589
|
if (relFile.endsWith('.astro')) {
|
|
590
|
+
// Map locally-imported asset bindings (e.g. `import hero from './hero.png'`)
|
|
591
|
+
// to the absolute on-disk path so dev-mode optimized URLs that embed that
|
|
592
|
+
// path (e.g. `/@image/...?f=/abs/path/hero.png`) can resolve back to the
|
|
593
|
+
// `<Image src={hero} />` JSX site.
|
|
594
|
+
const importedAssetAbsPath = buildImportedAssetMap(cached.imports, relFile)
|
|
595
|
+
|
|
575
596
|
function visit(node: AstroNode, parentExpression: AstroNode | null) {
|
|
576
597
|
// Track the nearest ancestor expression node (contains .map() context)
|
|
577
598
|
const currentExpr = node.type === 'expression' ? node : parentExpression
|
|
@@ -585,24 +606,23 @@ export function indexFileImages(cached: CachedParsedFile, relFile: string): void
|
|
|
585
606
|
for (const attr of elemNode.attributes) {
|
|
586
607
|
if (attr.type !== 'attribute' || attr.name !== 'src' || !attr.value) continue
|
|
587
608
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
609
|
+
const isExpression = (attr as any).kind === 'expression'
|
|
610
|
+
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
611
|
+
|
|
612
|
+
if (isExpression) {
|
|
613
|
+
const importedAbs = importedAssetAbsPath.get(attr.value)
|
|
614
|
+
if (importedAbs) {
|
|
615
|
+
const snippet = extractImageSnippet(cached.lines, srcLine - 1)
|
|
616
|
+
addToImageSearchIndex({ file: relFile, line: srcLine, snippet, src: importedAbs })
|
|
617
|
+
}
|
|
618
|
+
if (currentExpr) {
|
|
619
|
+
// `.map()`-driven src={item.foo} — trace through to data source
|
|
620
|
+
indexExpressionImageSrc(attr.value, currentExpr, cached, relFile)
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
597
623
|
// Static src="..." — index directly
|
|
598
|
-
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
599
624
|
const snippet = extractImageSnippet(cached.lines, srcLine - 1)
|
|
600
|
-
addToImageSearchIndex({
|
|
601
|
-
file: relFile,
|
|
602
|
-
line: srcLine,
|
|
603
|
-
snippet,
|
|
604
|
-
src: attr.value,
|
|
605
|
-
})
|
|
625
|
+
addToImageSearchIndex({ file: relFile, line: srcLine, snippet, src: attr.value })
|
|
606
626
|
}
|
|
607
627
|
}
|
|
608
628
|
}
|
|
@@ -1109,23 +1129,27 @@ function extractPathname(src: string): string {
|
|
|
1109
1129
|
export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
|
|
1110
1130
|
const index = getImageSearchIndex()
|
|
1111
1131
|
|
|
1132
|
+
// Dev-mode optimized URLs (`/_image?href=...`, `/@image/...?f=...`) embed the source
|
|
1133
|
+
// path; try both the raw URL and the decoded path so callers don't need to pre-decode.
|
|
1134
|
+
const decoded = extractAstroImageOriginalUrl(imageSrc)
|
|
1135
|
+
const candidates = decoded && decoded !== imageSrc ? [imageSrc, decoded] : [imageSrc]
|
|
1136
|
+
|
|
1112
1137
|
// Exact match — prefer collection data files (src/content/) over templates.
|
|
1113
1138
|
// The same image URL can appear in both a collection data file and a template
|
|
1114
1139
|
// that statically renders the collection. The data file is the authoritative source.
|
|
1115
1140
|
let bestMatch: SourceLocation | undefined
|
|
1116
1141
|
for (const entry of index) {
|
|
1117
|
-
if (entry.src
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1127
|
-
bestMatch ??= result // Keep first non-collection match as fallback
|
|
1142
|
+
if (!candidates.includes(entry.src)) continue
|
|
1143
|
+
const result: SourceLocation = {
|
|
1144
|
+
file: entry.file,
|
|
1145
|
+
line: entry.line,
|
|
1146
|
+
snippet: entry.snippet,
|
|
1147
|
+
type: 'static',
|
|
1148
|
+
}
|
|
1149
|
+
if (isCollectionFile(entry.file)) {
|
|
1150
|
+
return result // Collection data file — always preferred
|
|
1128
1151
|
}
|
|
1152
|
+
bestMatch ??= result // Keep first non-collection match as fallback
|
|
1129
1153
|
}
|
|
1130
1154
|
if (bestMatch) return bestMatch
|
|
1131
1155
|
|
|
@@ -1499,16 +1499,21 @@ export function inferCollectionFromAstroImageUrl(
|
|
|
1499
1499
|
}
|
|
1500
1500
|
|
|
1501
1501
|
/**
|
|
1502
|
-
* Extract the original image path from
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1502
|
+
* Extract the original image path from a dev-mode optimized image URL.
|
|
1503
|
+
* Recognizes:
|
|
1504
|
+
* - Astro's `<Image>`: `/_image?href=%2Fpath.jpg&w=...` → `href` param
|
|
1505
|
+
* - astro-imagetools / vite-imagetools: `/@image/<hash>.<ext>?f=<abs-path>&...` → `f` param
|
|
1505
1506
|
*/
|
|
1506
1507
|
export function extractAstroImageOriginalUrl(src: string): string | undefined {
|
|
1507
1508
|
try {
|
|
1508
1509
|
const url = new URL(src, 'http://localhost')
|
|
1509
1510
|
if (url.pathname === '/_image' || url.pathname.startsWith('/_image/')) {
|
|
1510
1511
|
const href = url.searchParams.get('href')
|
|
1511
|
-
if (href
|
|
1512
|
+
if (href) return href
|
|
1513
|
+
}
|
|
1514
|
+
if (url.pathname.startsWith('/@image/')) {
|
|
1515
|
+
const f = url.searchParams.get('f')
|
|
1516
|
+
if (f) return f
|
|
1512
1517
|
}
|
|
1513
1518
|
} catch {
|
|
1514
1519
|
// Not a valid URL
|