From 6d5235fd45999bc491a758b4389499061c78c1ac Mon Sep 17 00:00:00 2001 From: tommy230 Date: Tue, 18 Aug 2026 20:18:26 +0000 Subject: [PATCH] fix(site-import): resolve assets below exporter archive roots --- docs/features/site-import.md | 4 +- src/__tests__/siteImport/assetPlan.test.ts | 85 ++++++++++++++++++++++ src/core/siteImport/assetPlan.ts | 46 ++++++++---- 3 files changed, 121 insertions(+), 14 deletions(-) diff --git a/docs/features/site-import.md b/docs/features/site-import.md index d30c9be35..f02678f2a 100644 --- a/docs/features/site-import.md +++ b/docs/features/site-import.md @@ -121,7 +121,9 @@ User drops files / folder / static .zip / CMS bundle .zip ▼ buildAssetPlan(pagePlans, cssFileResults, fileMap, rawStylesheetSources) │ normalizes url() in node props, HTML attributes, CSS values, raw @keyframes - │ CSS, and kept-stylesheet text to FileMap keys + │ CSS, and kept-stylesheet text to FileMap keys; after exact and + │ punctuation-insensitive misses, a unique path suffix can match + │ files stored below an exporter-specific archive directory │ resolves @font-face → ImportFontFamily[] │ flattens kept stylesheets (mode 'file') → ImportStylesheet[] │ collects deduplicated asset list diff --git a/src/__tests__/siteImport/assetPlan.test.ts b/src/__tests__/siteImport/assetPlan.test.ts index 9ad60c4eb..148a8cbf8 100644 --- a/src/__tests__/siteImport/assetPlan.test.ts +++ b/src/__tests__/siteImport/assetPlan.test.ts @@ -45,6 +45,91 @@ describe('buildAssetPlan — img src normalisation', () => { expect(assets.some((a) => a.sourcePath === 'images/hero.png')).toBe(true) }) + it('matches a root-relative img src to a unique FileMap suffix', () => { + const sourcePath = 'passthrough/wp-content/uploads/x.png' + const fileMap = makeFileMap({ + 'index.html': { bytes: txt('') }, + [sourcePath]: { bytes: MINIMAL_PNG, mimeType: 'image/png' }, + }) + const { pagePlan } = makeHtmlPagePlan( + 'index.html', + new TextDecoder().decode(fileMap.files['index.html']!.bytes), + fileMap, + ) + const { normalizedPagePlans, assets } = buildAssetPlan([pagePlan], [], fileMap) + + const imageNode = Object.values(normalizedPagePlans[0].nodeFragment.nodes).find( + (node) => node.moduleId === 'base.image', + ) + expect(imageNode?.props['src']).toBe(sourcePath) + expect(assets.some((asset) => asset.sourcePath === sourcePath)).toBe(true) + }) + + it('prefers an exact FileMap key over a suffix match', () => { + const exactPath = 'wp-content/uploads/x.png' + const prefixedPath = `export-root/${exactPath}` + const exactBytes = txt('exact') + const fileMap = makeFileMap({ + 'index.html': { bytes: txt('') }, + [exactPath]: { bytes: exactBytes, mimeType: 'image/png' }, + [prefixedPath]: { bytes: txt('prefixed'), mimeType: 'image/png' }, + }) + const { pagePlan } = makeHtmlPagePlan( + 'index.html', + new TextDecoder().decode(fileMap.files['index.html']!.bytes), + fileMap, + ) + const { normalizedPagePlans, assets } = buildAssetPlan([pagePlan], [], fileMap) + + const imageNode = Object.values(normalizedPagePlans[0].nodeFragment.nodes).find( + (node) => node.moduleId === 'base.image', + ) + expect(imageNode?.props['src']).toBe(exactPath) + expect(assets.find((asset) => asset.sourcePath === exactPath)?.bytes).toBe(exactBytes) + }) + + it('leaves an ambiguous suffix unresolved and warns', () => { + const rawSrc = '/wp-content/uploads/x.png' + const fileMap = makeFileMap({ + 'index.html': { bytes: txt(``) }, + 'first/wp-content/uploads/x.png': { bytes: MINIMAL_PNG, mimeType: 'image/png' }, + 'second/wp-content/uploads/x.png': { bytes: MINIMAL_PNG, mimeType: 'image/png' }, + }) + const { pagePlan } = makeHtmlPagePlan( + 'index.html', + new TextDecoder().decode(fileMap.files['index.html']!.bytes), + fileMap, + ) + const { normalizedPagePlans, warnings } = buildAssetPlan([pagePlan], [], fileMap) + + const imageNode = Object.values(normalizedPagePlans[0].nodeFragment.nodes).find( + (node) => node.moduleId === 'base.image', + ) + expect(imageNode?.props['src']).toBe(rawSrc) + expect(warnings.map((warning) => warning.kind)).toEqual(['unresolved-asset']) + expect(warnings[0]?.path).toBe('wp-content/uploads/x.png') + }) + + it('requires a suffix match to start at a path-segment boundary', () => { + const rawSrc = '/content/uploads/x.png' + const fileMap = makeFileMap({ + 'index.html': { bytes: txt(``) }, + 'wp-content/uploads/x.png': { bytes: MINIMAL_PNG, mimeType: 'image/png' }, + }) + const { pagePlan } = makeHtmlPagePlan( + 'index.html', + new TextDecoder().decode(fileMap.files['index.html']!.bytes), + fileMap, + ) + const { normalizedPagePlans, warnings } = buildAssetPlan([pagePlan], [], fileMap) + + const imageNode = Object.values(normalizedPagePlans[0].nodeFragment.nodes).find( + (node) => node.moduleId === 'base.image', + ) + expect(imageNode?.props['src']).toBe(rawSrc) + expect(warnings.map((warning) => warning.kind)).toEqual(['unresolved-asset']) + }) + it('leaves external URLs unchanged', () => { const fileMap = makeFileMap({ 'index.html': { bytes: txt('') }, diff --git a/src/core/siteImport/assetPlan.ts b/src/core/siteImport/assetPlan.ts index b130389f1..0eebbade5 100644 --- a/src/core/siteImport/assetPlan.ts +++ b/src/core/siteImport/assetPlan.ts @@ -102,8 +102,8 @@ interface AssetPlanResult { * The four normalisers below (node props, CSS bags, raw CSS text, `@font-face`) * all funnel into `resolveAndRecord`, and all of them need the same four * things — so they take the resolver rather than passing the pieces around - * individually. The two caches live here for the same reason: they are per - * import, not per call. + * individually. The lookup indexes live here for the same reason: they are + * per import, not per call. */ interface AssetResolver { fileMap: FileMap @@ -112,6 +112,8 @@ interface AssetResolver { warnings: ImportWarning[] /** Lazily built by `normalizedIndex` — see the note there. */ byNormalizedPath?: ReadonlyMap + /** Lazily built by `suffixIndex` — see the note there. */ + bySuffixPath?: ReadonlyMap /** One `unresolved-asset` warning per path, however many pages reference it. */ reportedMissing: Set } @@ -588,17 +590,13 @@ function resolveAndRecord(rawUrl: string, basePath: string, resolver: AssetResol * Turn a resolved archive path into the FileMap key that actually holds the * bytes, and report the ones that hold nothing. * - * An exact hit is the normal case. The fallback exists because exporters do - * not always agree with themselves about filenames: a file stored as - * `101-&Berlin-Office-Us+ Coworking.webp` gets referenced from the HTML as - * `101-Berlin-Office-Us-Coworking.webp`, and the page imports with a broken - * image through no fault of the archive's owner. Comparing punctuation- - * insensitively reunites the two. + * Exporters do not always agree with themselves about filenames or archive + * roots. Punctuation-insensitive matching rejoins filename variants, while a + * unique path suffix finds files stored below an extra archive directory. * - * The match must be UNIQUE. Two files that differ only in punctuation are two - * different files, and picking one would silently put the wrong image on the - * page — a worse outcome than the broken reference we started with. Ambiguity - * and genuine absence both fall through to the same warning. + * A fallback match must be UNIQUE. Picking between equivalent candidates would + * silently put the wrong image on the page. Ambiguity and genuine absence both + * fall through to the same warning. */ function resolveFileMapKey(resolvedPath: string, resolver: AssetResolver): string | null { if (resolver.fileMap.files[resolvedPath]) return resolvedPath @@ -606,6 +604,9 @@ function resolveFileMapKey(resolvedPath: string, resolver: AssetResolver): strin const match = normalizedIndex(resolver).get(normalizeAssetPath(resolvedPath)) if (match) return match + const suffixMatch = suffixIndex(resolver).get(resolvedPath) + if (suffixMatch) return suffixMatch + // Only media references are worth reporting. Anchors point at pages and // extensionless routes that legitimately live outside the archive, and a // wall of warnings about those would bury the images that really are gone. @@ -639,6 +640,26 @@ function normalizedIndex(resolver: AssetResolver): ReadonlyMap { + if (resolver.bySuffixPath) return resolver.bySuffixPath + + const index = new Map() + for (const filePath of Object.keys(resolver.fileMap.files)) { + let separatorIndex = filePath.indexOf('/') + while (separatorIndex !== -1) { + const suffix = filePath.slice(separatorIndex + 1) + index.set(suffix, index.has(suffix) ? null : filePath) + separatorIndex = filePath.indexOf('/', separatorIndex + 1) + } + } + resolver.bySuffixPath = index + return index +} + /** * Strip a path down to what survives an exporter's filename sanitising: * lowercase, with everything but letters, digits, `.` and `/` removed. That @@ -674,4 +695,3 @@ function replaceRawUrlInValue(value: string, rawUrl: string, fileMapKey: string) const re = new RegExp(`url\\(\\s*(['"]?)${escaped}\\1\\s*\\)`, 'g') return value.replace(re, `url('${fileMapKey}')`) } -