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}')`)
}
-