@nuasite/cms 0.38.0 → 0.39.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 +11483 -11444
- package/package.json +1 -1
- package/src/build-processor.ts +17 -3
- package/src/dev-middleware.ts +67 -9
- package/src/editor/components/markdown-editor-overlay.tsx +2 -0
- package/src/editor/constants.ts +2 -0
- package/src/editor/editor.ts +24 -7
- package/src/editor/index.tsx +11 -6
- package/src/editor/markdown-api.ts +9 -0
- package/src/editor/signals.ts +113 -7
- package/src/editor/storage.ts +172 -196
- package/src/editor/types.ts +2 -0
- package/src/handlers/api-routes.ts +27 -14
- package/src/handlers/request-utils.ts +10 -1
- package/src/html-processor.ts +11 -0
- package/src/index.ts +21 -10
- package/src/source-finder/image-finder.ts +9 -2
- package/src/source-finder/search-index.ts +152 -62
- package/src/source-finder/snippet-utils.ts +16 -3
- package/src/source-finder/source-lookup.ts +5 -1
- package/src/types.ts +4 -0
package/src/index.ts
CHANGED
|
@@ -65,8 +65,16 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
65
65
|
* @default false
|
|
66
66
|
*/
|
|
67
67
|
usePolling?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* Maximum upload size in bytes. Applied to `/_nua/cms/media/upload` requests
|
|
70
|
+
* (enforced on the server and pre-checked on the client).
|
|
71
|
+
* @default 10 * 1024 * 1024 (10 MB)
|
|
72
|
+
*/
|
|
73
|
+
maxUploadSize?: number
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
const DEFAULT_MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
|
77
|
+
|
|
70
78
|
const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
|
|
71
79
|
|
|
72
80
|
export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
@@ -89,16 +97,20 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
89
97
|
mdxComponentDirs,
|
|
90
98
|
usePolling = false,
|
|
91
99
|
seo = { trackSeo: true, markTitle: true, parseJsonLd: true },
|
|
100
|
+
maxUploadSize = DEFAULT_MAX_UPLOAD_SIZE,
|
|
92
101
|
} = options
|
|
93
102
|
|
|
94
103
|
// When no proxy, enable local CMS API with default media adapter
|
|
95
104
|
const enableCmsApi = !proxy
|
|
96
105
|
const mediaAdapter = media ?? (enableCmsApi ? createLocalStorageAdapter() : undefined)
|
|
97
106
|
|
|
98
|
-
// Default apiBase to local dev server when no proxy
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
// Default apiBase to local dev server when no proxy; thread maxUploadSize through
|
|
108
|
+
// to the editor so the client can pre-check before sending the upload.
|
|
109
|
+
const resolvedCmsConfig = {
|
|
110
|
+
...(cmsConfig ?? {}),
|
|
111
|
+
...(enableCmsApi && !cmsConfig?.apiBase ? { apiBase: '/_nua/cms' } : {}),
|
|
112
|
+
maxUploadSize,
|
|
113
|
+
}
|
|
102
114
|
|
|
103
115
|
let componentDefinitions: Record<string, ComponentDefinition> = {}
|
|
104
116
|
let isPublicStaticFile: ((urlPath: string) => boolean) | undefined
|
|
@@ -203,9 +215,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
203
215
|
if (command === 'dev') {
|
|
204
216
|
const editorSrc = src ?? VIRTUAL_CMS_PATH
|
|
205
217
|
|
|
206
|
-
const configScript = resolvedCmsConfig
|
|
207
|
-
? `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
|
|
208
|
-
: ''
|
|
218
|
+
const configScript = `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
|
|
209
219
|
|
|
210
220
|
injectScript(
|
|
211
221
|
'page',
|
|
@@ -229,7 +239,8 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
229
239
|
if (hasPrebuiltBundle) {
|
|
230
240
|
// Pre-built bundle exists (npm install case):
|
|
231
241
|
// Serve it via a virtual module — no JSX pragma, Tailwind, or aliases needed.
|
|
232
|
-
|
|
242
|
+
// Read on every load() so rebuilds during dev pick up without restarting
|
|
243
|
+
// the host (Astro, pletivo, etc).
|
|
233
244
|
vitePlugins.push({
|
|
234
245
|
name: 'nuasite-cms-editor',
|
|
235
246
|
resolveId(id: string) {
|
|
@@ -239,7 +250,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
239
250
|
},
|
|
240
251
|
load(id: string) {
|
|
241
252
|
if (id === VIRTUAL_CMS_PATH) {
|
|
242
|
-
return
|
|
253
|
+
return readFileSync(editorBundlePath!, 'utf-8')
|
|
243
254
|
}
|
|
244
255
|
},
|
|
245
256
|
})
|
|
@@ -319,7 +330,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
319
330
|
manifestWriter,
|
|
320
331
|
componentDefinitions,
|
|
321
332
|
idCounter,
|
|
322
|
-
{ enableCmsApi, mediaAdapter, isPublicStaticFile },
|
|
333
|
+
{ enableCmsApi, mediaAdapter, isPublicStaticFile, maxUploadSize },
|
|
323
334
|
)
|
|
324
335
|
logger.info('CMS dev middleware initialized')
|
|
325
336
|
if (enableCmsApi) {
|
|
@@ -141,21 +141,28 @@ function parseSrcsetUrls(srcSet: string): string[] {
|
|
|
141
141
|
* Also checks srcset URLs as fallback when src doesn't match (e.g., when src
|
|
142
142
|
* is a local upload path but srcset contains CDN-transformed original URLs).
|
|
143
143
|
* Uses pre-built search index for fast lookups.
|
|
144
|
+
*
|
|
145
|
+
* `pageFiles` disambiguates the same image URL across pages.
|
|
146
|
+
* `preferredLocation` disambiguates the same image URL within one page —
|
|
147
|
+
* pass the manifest entry's Astro attribution and the lookup will return the
|
|
148
|
+
* matching per-occurrence index entry when one exists.
|
|
144
149
|
*/
|
|
145
150
|
export async function findImageSourceLocation(
|
|
146
151
|
imageSrc: string,
|
|
147
152
|
imageSrcSet?: string,
|
|
153
|
+
pageFiles?: readonly string[],
|
|
154
|
+
preferredLocation?: { file: string; line?: number; srcOccurrence?: number },
|
|
148
155
|
): Promise<SourceLocation | undefined> {
|
|
149
156
|
// Use index if available (much faster)
|
|
150
157
|
if (isSearchIndexInitialized()) {
|
|
151
|
-
const result = findInImageIndex(imageSrc)
|
|
158
|
+
const result = findInImageIndex(imageSrc, pageFiles, preferredLocation)
|
|
152
159
|
if (result) return result
|
|
153
160
|
|
|
154
161
|
// Fallback: try URLs extracted from srcset
|
|
155
162
|
if (imageSrcSet) {
|
|
156
163
|
const srcsetUrls = parseSrcsetUrls(imageSrcSet)
|
|
157
164
|
for (const url of srcsetUrls) {
|
|
158
|
-
const srcsetResult = findInImageIndex(url)
|
|
165
|
+
const srcsetResult = findInImageIndex(url, pageFiles, preferredLocation)
|
|
159
166
|
if (srcsetResult) return srcsetResult
|
|
160
167
|
}
|
|
161
168
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ComponentNode, ElementNode, Node as AstroNode, TextNode } from '@astrojs/compiler/types'
|
|
2
|
+
import { parse as parseBabel } from '@babel/parser'
|
|
3
|
+
import type { Expression } from '@babel/types'
|
|
2
4
|
import fs from 'node:fs/promises'
|
|
3
5
|
import path from 'node:path'
|
|
4
6
|
|
|
@@ -22,7 +24,7 @@ import {
|
|
|
22
24
|
setSearchIndexInitialized,
|
|
23
25
|
} from './cache'
|
|
24
26
|
import { extractAstroImageOriginalUrl, extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
|
|
25
|
-
import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
|
|
27
|
+
import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry, SourceLocation } from './types'
|
|
26
28
|
|
|
27
29
|
/** Collection data files live under this path — used to prefer them over templates */
|
|
28
30
|
const CONTENT_DIR_PREFIX = 'src/content/'
|
|
@@ -622,6 +624,42 @@ function parseMapInvocations(fullText: string): MapInvocation[] {
|
|
|
622
624
|
return maps
|
|
623
625
|
}
|
|
624
626
|
|
|
627
|
+
const BARE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
628
|
+
|
|
629
|
+
function walkAccessor(node: Expression): { base: string; suffix: string } | null {
|
|
630
|
+
if (node.type === 'Identifier') {
|
|
631
|
+
return { base: node.name, suffix: '' }
|
|
632
|
+
}
|
|
633
|
+
if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
|
|
634
|
+
if (node.object.type === 'Super') return null
|
|
635
|
+
const inner = walkAccessor(node.object)
|
|
636
|
+
if (!inner) return null
|
|
637
|
+
const { property, computed } = node
|
|
638
|
+
let part: string
|
|
639
|
+
if (!computed && property.type === 'Identifier') {
|
|
640
|
+
part = `.${property.name}`
|
|
641
|
+
} else if (computed && property.type === 'NumericLiteral') {
|
|
642
|
+
part = `[${property.value}]`
|
|
643
|
+
} else {
|
|
644
|
+
return null
|
|
645
|
+
}
|
|
646
|
+
return { base: inner.base, suffix: inner.suffix + part }
|
|
647
|
+
}
|
|
648
|
+
return null
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function parseAccessorChain(exprText: string): { base: string; suffix: string } | null {
|
|
652
|
+
if (BARE_IDENTIFIER.test(exprText)) return { base: exprText, suffix: '' }
|
|
653
|
+
try {
|
|
654
|
+
const file = parseBabel(exprText, { sourceType: 'module', plugins: ['typescript'] })
|
|
655
|
+
const stmt = file.program.body[0]
|
|
656
|
+
if (!stmt || stmt.type !== 'ExpressionStatement') return null
|
|
657
|
+
return walkAccessor(stmt.expression)
|
|
658
|
+
} catch {
|
|
659
|
+
return null
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
625
663
|
/**
|
|
626
664
|
* Resolve a `.map()` callback parameter back to the source array path.
|
|
627
665
|
*
|
|
@@ -632,6 +670,8 @@ function parseMapInvocations(fullText: string): MapInvocation[] {
|
|
|
632
670
|
* → `{ arrayPath: "categories[*].images", leafSuffix: "" }`
|
|
633
671
|
* `links.map(({ label, href }) => …)` looking for `label`
|
|
634
672
|
* → `{ arrayPath: "links", leafSuffix: ".label" }`
|
|
673
|
+
* `services.map((service) => …)` looking for `service.image`
|
|
674
|
+
* → `{ arrayPath: "services", leafSuffix: ".image" }`
|
|
635
675
|
*
|
|
636
676
|
* Returns null when the name doesn't appear as a parameter or destructured binding.
|
|
637
677
|
*/
|
|
@@ -639,13 +679,16 @@ export function resolveMapChain(exprTexts: string[], paramName: string): Resolve
|
|
|
639
679
|
const maps = parseMapInvocations(exprTexts.join(''))
|
|
640
680
|
if (maps.length === 0) return null
|
|
641
681
|
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
682
|
+
const access = parseAccessorChain(paramName)
|
|
683
|
+
const baseName = access?.base ?? paramName
|
|
684
|
+
const memberSuffix = access?.suffix ?? ''
|
|
685
|
+
|
|
686
|
+
const directMap = maps.find((m) => m.param === baseName)
|
|
687
|
+
?? maps.find((m) => m.destructured.includes(baseName))
|
|
645
688
|
if (!directMap) return null
|
|
646
689
|
|
|
647
|
-
const isDestructured = directMap.param !==
|
|
648
|
-
const leafSuffix = isDestructured ? `.${
|
|
690
|
+
const isDestructured = directMap.param !== baseName
|
|
691
|
+
const leafSuffix = (isDestructured ? `.${baseName}` : '') + memberSuffix
|
|
649
692
|
|
|
650
693
|
// Resolve the array expression by substituting outer .map() params (chained / nested loops).
|
|
651
694
|
let arrayPath = directMap.arrayExpr
|
|
@@ -1225,6 +1268,37 @@ function textEntryToLocation(entry: SearchIndexEntry): SourceLocation {
|
|
|
1225
1268
|
}
|
|
1226
1269
|
}
|
|
1227
1270
|
|
|
1271
|
+
/** Helper to build SourceLocation from an image index entry */
|
|
1272
|
+
function imageEntryToLocation(entry: ImageIndexEntry): SourceLocation {
|
|
1273
|
+
return {
|
|
1274
|
+
file: entry.file,
|
|
1275
|
+
line: entry.line,
|
|
1276
|
+
snippet: entry.snippet,
|
|
1277
|
+
type: 'static',
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Classify a candidate result by file priority and stash it into the right
|
|
1283
|
+
* bucket. Returns the result if it's a collection-file match (caller should
|
|
1284
|
+
* return immediately — collection data files are always authoritative).
|
|
1285
|
+
*/
|
|
1286
|
+
interface RankedMatches {
|
|
1287
|
+
page?: SourceLocation
|
|
1288
|
+
other?: SourceLocation
|
|
1289
|
+
}
|
|
1290
|
+
function rankAndStash(
|
|
1291
|
+
file: string,
|
|
1292
|
+
result: SourceLocation,
|
|
1293
|
+
pageFiles: readonly string[] | undefined,
|
|
1294
|
+
matches: RankedMatches,
|
|
1295
|
+
): SourceLocation | undefined {
|
|
1296
|
+
if (isCollectionFile(file)) return result
|
|
1297
|
+
if (pageFiles?.includes(file)) matches.page ??= result
|
|
1298
|
+
else matches.other ??= result
|
|
1299
|
+
return undefined
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1228
1302
|
/**
|
|
1229
1303
|
* Look up an i18n dictionary entry by its literal key (e.g. `nav.prague4`),
|
|
1230
1304
|
* preferring the match whose value equals `normalizedText` so the right locale
|
|
@@ -1282,55 +1356,56 @@ function toProjectRelativePath(file: string): string {
|
|
|
1282
1356
|
}
|
|
1283
1357
|
|
|
1284
1358
|
/**
|
|
1285
|
-
* Fast text lookup using pre-built index
|
|
1359
|
+
* Fast text lookup using pre-built index.
|
|
1360
|
+
*
|
|
1361
|
+
* Priority order: collection data files > entries in `pageFiles` > anything
|
|
1362
|
+
* else. Translation-tag (i18n dictionary) hits beat non-collection template
|
|
1363
|
+
* matches — a dictionary entry is an authoritative translatable signal
|
|
1364
|
+
* whereas a same-text template hit is often coincidental.
|
|
1286
1365
|
*/
|
|
1287
|
-
export function findInTextIndex(
|
|
1366
|
+
export function findInTextIndex(
|
|
1367
|
+
textContent: string,
|
|
1368
|
+
tag: string,
|
|
1369
|
+
pageFiles?: readonly string[],
|
|
1370
|
+
): SourceLocation | undefined {
|
|
1288
1371
|
const normalizedSearch = normalizeText(textContent)
|
|
1289
1372
|
const tagLower = tag.toLowerCase()
|
|
1290
1373
|
const index = getTextSearchIndex()
|
|
1291
|
-
|
|
1292
|
-
// Single pass for exact matches: collect the best same-tag template hit
|
|
1293
|
-
// *and* any i18n dictionary hit at once. A JSON dictionary entry is an
|
|
1294
|
-
// authoritative translatable signal, so it beats a non-collection
|
|
1295
|
-
// template match (which is often a coincidental same-text element).
|
|
1296
|
-
let bestMatch: SourceLocation | undefined
|
|
1374
|
+
const matches: RankedMatches = {}
|
|
1297
1375
|
let translationHit: SourceLocation | undefined
|
|
1376
|
+
|
|
1298
1377
|
for (const entry of index) {
|
|
1299
1378
|
if (entry.normalizedText !== normalizedSearch) continue
|
|
1300
1379
|
if (entry.tag === tagLower) {
|
|
1301
|
-
const
|
|
1302
|
-
if (
|
|
1303
|
-
bestMatch ??= result
|
|
1380
|
+
const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
|
|
1381
|
+
if (collectionHit) return collectionHit
|
|
1304
1382
|
} else if (entry.tag === TRANSLATION_TAG_MARKER) {
|
|
1305
1383
|
translationHit ??= textEntryToLocation(entry)
|
|
1306
1384
|
}
|
|
1307
1385
|
}
|
|
1308
1386
|
if (translationHit) return translationHit
|
|
1309
|
-
|
|
1387
|
+
const sameTag = matches.page ?? matches.other
|
|
1388
|
+
if (sameTag) return sameTag
|
|
1310
1389
|
|
|
1311
|
-
// Then try partial match for longer text — prefer collection data files
|
|
1312
1390
|
if (normalizedSearch.length > 10) {
|
|
1313
1391
|
const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
|
|
1314
1392
|
for (const entry of index) {
|
|
1315
|
-
if (entry.tag
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
}
|
|
1393
|
+
if (entry.tag !== tagLower) continue
|
|
1394
|
+
if (!entry.normalizedText.includes(textPreview)) continue
|
|
1395
|
+
const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
|
|
1396
|
+
if (collectionHit) return collectionHit
|
|
1320
1397
|
}
|
|
1321
|
-
|
|
1398
|
+
const partial = matches.page ?? matches.other
|
|
1399
|
+
if (partial) return partial
|
|
1322
1400
|
}
|
|
1323
1401
|
|
|
1324
|
-
// Try any tag match — prefer collection data files
|
|
1325
1402
|
for (const entry of index) {
|
|
1326
|
-
if (entry.normalizedText
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
bestMatch ??= result
|
|
1330
|
-
}
|
|
1403
|
+
if (entry.normalizedText !== normalizedSearch) continue
|
|
1404
|
+
const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
|
|
1405
|
+
if (collectionHit) return collectionHit
|
|
1331
1406
|
}
|
|
1332
1407
|
|
|
1333
|
-
return
|
|
1408
|
+
return matches.page ?? matches.other
|
|
1334
1409
|
}
|
|
1335
1410
|
|
|
1336
1411
|
/**
|
|
@@ -1345,34 +1420,57 @@ function extractPathname(src: string): string {
|
|
|
1345
1420
|
}
|
|
1346
1421
|
|
|
1347
1422
|
/**
|
|
1348
|
-
* Fast image lookup using pre-built index
|
|
1423
|
+
* Fast image lookup using pre-built index.
|
|
1424
|
+
*
|
|
1425
|
+
* Priority order:
|
|
1426
|
+
* 1. `preferredLocation` (file, srcOccurrence) — Nth index entry for
|
|
1427
|
+
* (src, file). Deterministic per-DOM-order; preferred over `line` because
|
|
1428
|
+
* Astro's `data-astro-source-loc` often points at a common ancestor and
|
|
1429
|
+
* collides for multiple imgs sharing the same src.
|
|
1430
|
+
* 2. `preferredLocation` (file, line) exact match — used when occurrence
|
|
1431
|
+
* isn't provided (Astro's stamping happens to be precise enough).
|
|
1432
|
+
* 3. Collection data files (always authoritative).
|
|
1433
|
+
* 4. Entries in `pageFiles` — disambiguates same image across multiple pages.
|
|
1434
|
+
* 5. Any other match.
|
|
1349
1435
|
*/
|
|
1350
|
-
export function findInImageIndex(
|
|
1436
|
+
export function findInImageIndex(
|
|
1437
|
+
imageSrc: string,
|
|
1438
|
+
pageFiles?: readonly string[],
|
|
1439
|
+
preferredLocation?: { file: string; line?: number; srcOccurrence?: number },
|
|
1440
|
+
): SourceLocation | undefined {
|
|
1351
1441
|
const index = getImageSearchIndex()
|
|
1352
1442
|
|
|
1353
1443
|
// Dev-mode optimized URLs (`/_image?href=...`, `/@image/...?f=...`) embed the source
|
|
1354
1444
|
// path; try both the raw URL and the decoded path so callers don't need to pre-decode.
|
|
1355
1445
|
const decoded = extractAstroImageOriginalUrl(imageSrc)
|
|
1356
1446
|
const candidates = decoded && decoded !== imageSrc ? [imageSrc, decoded] : [imageSrc]
|
|
1357
|
-
|
|
1358
|
-
//
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1447
|
+
// Astro stamps absolute paths in `data-astro-source-file`; the index uses
|
|
1448
|
+
// project-relative paths. Normalize before comparing.
|
|
1449
|
+
const preferredFile = preferredLocation ? toProjectRelativePath(preferredLocation.file) : undefined
|
|
1450
|
+
const preferredLine = preferredLocation?.line
|
|
1451
|
+
const preferredOccurrence = preferredLocation?.srcOccurrence
|
|
1452
|
+
|
|
1453
|
+
const matches: RankedMatches = {}
|
|
1454
|
+
let occurrenceCounter = 0
|
|
1455
|
+
let occurrenceMatch: SourceLocation | undefined
|
|
1456
|
+
let lineMatch: SourceLocation | undefined
|
|
1362
1457
|
for (const entry of index) {
|
|
1363
1458
|
if (!candidates.includes(entry.src)) continue
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
return result // Collection data file — always preferred
|
|
1459
|
+
if (preferredFile && entry.file === preferredFile) {
|
|
1460
|
+
if (preferredOccurrence !== undefined && occurrenceCounter++ === preferredOccurrence) {
|
|
1461
|
+
occurrenceMatch ??= imageEntryToLocation(entry)
|
|
1462
|
+
}
|
|
1463
|
+
if (preferredLine !== undefined && entry.line === preferredLine) {
|
|
1464
|
+
lineMatch ??= imageEntryToLocation(entry)
|
|
1465
|
+
}
|
|
1372
1466
|
}
|
|
1373
|
-
|
|
1467
|
+
const collectionHit = rankAndStash(entry.file, imageEntryToLocation(entry), pageFiles, matches)
|
|
1468
|
+
if (collectionHit) return collectionHit
|
|
1374
1469
|
}
|
|
1375
|
-
if (
|
|
1470
|
+
if (occurrenceMatch) return occurrenceMatch
|
|
1471
|
+
if (lineMatch) return lineMatch
|
|
1472
|
+
const exact = matches.page ?? matches.other
|
|
1473
|
+
if (exact) return exact
|
|
1376
1474
|
|
|
1377
1475
|
// Fallback: path suffix matching for CDN-transformed URLs
|
|
1378
1476
|
// e.g., rendered src "/cdn-cgi/image/.../assets/photo.webp" should match
|
|
@@ -1380,19 +1478,11 @@ export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
|
|
|
1380
1478
|
const targetPath = extractPathname(imageSrc)
|
|
1381
1479
|
for (const entry of index) {
|
|
1382
1480
|
const entryPath = extractPathname(entry.src)
|
|
1383
|
-
if (entryPath.length
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
snippet: entry.snippet,
|
|
1388
|
-
type: 'static',
|
|
1389
|
-
}
|
|
1390
|
-
if (isCollectionFile(entry.file)) {
|
|
1391
|
-
return result
|
|
1392
|
-
}
|
|
1393
|
-
bestMatch ??= result
|
|
1394
|
-
}
|
|
1481
|
+
if (entryPath.length <= 5) continue
|
|
1482
|
+
if (!targetPath.endsWith(entryPath) && !entryPath.endsWith(targetPath)) continue
|
|
1483
|
+
const collectionHit = rankAndStash(entry.file, imageEntryToLocation(entry), pageFiles, matches)
|
|
1484
|
+
if (collectionHit) return collectionHit
|
|
1395
1485
|
}
|
|
1396
1486
|
|
|
1397
|
-
return
|
|
1487
|
+
return matches.page ?? matches.other
|
|
1398
1488
|
}
|
|
@@ -629,6 +629,7 @@ function resolveTranslationKeyFromSnippet(
|
|
|
629
629
|
export async function enhanceManifestWithSourceSnippets(
|
|
630
630
|
entries: Record<string, ManifestEntry>,
|
|
631
631
|
collectionDefinitions?: Record<string, CollectionDefinition>,
|
|
632
|
+
pageFiles?: readonly string[],
|
|
632
633
|
): Promise<Record<string, ManifestEntry>> {
|
|
633
634
|
// Ensure the search index is ready (returns immediately if already built,
|
|
634
635
|
// otherwise waits for the in-flight initialization or triggers a new one).
|
|
@@ -750,7 +751,19 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
750
751
|
}
|
|
751
752
|
|
|
752
753
|
// ── Non-collection images: find via search index / AST ──
|
|
753
|
-
const
|
|
754
|
+
const preferredLocation = entry.sourcePath
|
|
755
|
+
? {
|
|
756
|
+
file: entry.sourcePath,
|
|
757
|
+
line: entry.sourceLine,
|
|
758
|
+
srcOccurrence: entry.imageMetadata.srcOccurrence,
|
|
759
|
+
}
|
|
760
|
+
: undefined
|
|
761
|
+
const imageLocation = await findImageSourceLocation(
|
|
762
|
+
entry.imageMetadata.src,
|
|
763
|
+
entry.imageMetadata.srcSet,
|
|
764
|
+
pageFiles,
|
|
765
|
+
preferredLocation,
|
|
766
|
+
)
|
|
754
767
|
if (imageLocation) {
|
|
755
768
|
const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
|
|
756
769
|
const updated: ManifestEntry = {
|
|
@@ -862,7 +875,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
862
875
|
? findVariableHitInFile(trimmedEntryText, entry.tag, entry.sourcePath)
|
|
863
876
|
: undefined
|
|
864
877
|
const noCoords = !entry.sourcePath || !entry.sourceLine
|
|
865
|
-
const winner = sameFileHit ?? (noCoords ? findInTextIndex(trimmedEntryText, entry.tag) : undefined)
|
|
878
|
+
const winner = sameFileHit ?? (noCoords ? findInTextIndex(trimmedEntryText, entry.tag, pageFiles) : undefined)
|
|
866
879
|
|
|
867
880
|
if (winner) {
|
|
868
881
|
if (isTranslationFilePath(winner.file)) {
|
|
@@ -1059,7 +1072,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
1059
1072
|
|
|
1060
1073
|
// Last resort — consult the text index (covers i18n JSON dictionaries
|
|
1061
1074
|
// and any other indexed text that shares no tag with the rendered element).
|
|
1062
|
-
const indexHit = findInTextIndex(trimmedText, entry.tag)
|
|
1075
|
+
const indexHit = findInTextIndex(trimmedText, entry.tag, pageFiles)
|
|
1063
1076
|
if (indexHit && indexHit.file !== entry.sourcePath) {
|
|
1064
1077
|
const resolved = await applyTranslationSource(entry, indexHit, attributes, colorClasses)
|
|
1065
1078
|
return [id, resolved] as const
|
|
@@ -17,14 +17,18 @@ import type { SourceLocation } from './types'
|
|
|
17
17
|
/**
|
|
18
18
|
* Find source file and line number for text content.
|
|
19
19
|
* Uses pre-built search index for fast lookups.
|
|
20
|
+
*
|
|
21
|
+
* `pageFiles` disambiguates when the same text appears on multiple pages —
|
|
22
|
+
* the lookup prefers index entries whose file is one of the candidates.
|
|
20
23
|
*/
|
|
21
24
|
export async function findSourceLocation(
|
|
22
25
|
textContent: string,
|
|
23
26
|
tag: string,
|
|
27
|
+
pageFiles?: readonly string[],
|
|
24
28
|
): Promise<SourceLocation | undefined> {
|
|
25
29
|
// Use index if available (much faster)
|
|
26
30
|
if (isSearchIndexInitialized()) {
|
|
27
|
-
const indexHit = findInTextIndex(textContent, tag)
|
|
31
|
+
const indexHit = findInTextIndex(textContent, tag, pageFiles)
|
|
28
32
|
if (indexHit) return indexHit
|
|
29
33
|
// Fall through to slow search on miss — covers cases the per-file
|
|
30
34
|
// indexer can't pre-emit, like a child component's `.map()` over a
|
package/src/types.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface ImageMetadata {
|
|
|
70
70
|
srcSet?: string
|
|
71
71
|
/** Image sizes attribute */
|
|
72
72
|
sizes?: string
|
|
73
|
+
/** 0-based DOM-order index of this `<img>` among same-(src, sourceFile)
|
|
74
|
+
* occurrences on the page. Disambiguates source location when the same
|
|
75
|
+
* image URL appears multiple times in the same source file. */
|
|
76
|
+
srcOccurrence?: number
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/** Identifies the (collection, entry, field) destination for an editor upload. */
|