Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/features/site-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/siteImport/assetPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html><body><img src="/wp-content/uploads/x.png"></body></html>') },
[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('<html><body><img src="/wp-content/uploads/x.png"></body></html>') },
[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(`<html><body><img src="${rawSrc}"></body></html>`) },
'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(`<html><body><img src="${rawSrc}"></body></html>`) },
'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('<html><body><img src="https://cdn.example.com/img.png"></body></html>') },
Expand Down
46 changes: 33 additions & 13 deletions src/core/siteImport/assetPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -112,6 +112,8 @@ interface AssetResolver {
warnings: ImportWarning[]
/** Lazily built by `normalizedIndex` — see the note there. */
byNormalizedPath?: ReadonlyMap<string, string | null>
/** Lazily built by `suffixIndex` — see the note there. */
bySuffixPath?: ReadonlyMap<string, string | null>
/** One `unresolved-asset` warning per path, however many pages reference it. */
reportedMissing: Set<string>
}
Expand Down Expand Up @@ -588,24 +590,23 @@ 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

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.
Expand Down Expand Up @@ -639,6 +640,26 @@ function normalizedIndex(resolver: AssetResolver): ReadonlyMap<string, string |
return index
}

/**
* Index FileMap keys by path-segment suffix. Shared suffixes map to `null`, and
* lazy construction avoids a full FileMap scan for every lookup.
*/
function suffixIndex(resolver: AssetResolver): ReadonlyMap<string, string | null> {
if (resolver.bySuffixPath) return resolver.bySuffixPath

const index = new Map<string, string | null>()
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
Expand Down Expand Up @@ -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}')`)
}

Loading