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
22 changes: 22 additions & 0 deletions docs/features/site-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,28 @@ On success the same step switches to its **complete** state — a success mark,
| `missing-stylesheet` | A `<link rel="stylesheet">` href was not found in the FileMap |
| `asset-upload-failed` | An individual asset upload was rejected by the server; the original FileMap path remains in the import |
| `external-font` | An `@font-face` with no bundled file (all `src` entries are external URLs) — skipped |
| `unresolved-asset` | An HTML/CSS reference to a media file the archive does not contain under that path — one warning per distinct path |

The import log shows the first 12 warnings, ordered so the kinds that name a
missing file come first and the CSS interpretation notes last
(`rankWarning` in `ImportStep.tsx`).

### Filename matching

Asset references resolve by exact FileMap key first. On a miss, the path is
compared punctuation-insensitively (lowercased, with everything but letters,
digits, `.` and `/` removed) against every file in the archive. This exists
because exporters do not always agree with themselves: a Webflow export stores
`101-&Berlin-Office-Us+ Coworking.webp` and references it from the HTML as
`101-Berlin-Office-Us-Coworking.webp`, so an exact-only match imports the page
with a broken image.

The fallback match must be **unique**. Two files that differ only in
punctuation are two different files, and picking one would put the wrong image
on the page — so ambiguity falls through to the same `unresolved-asset`
warning as genuine absence. Only references whose extension maps to an
uploadable media MIME are reported: anchors to extensionless routes and pages
outside the archive are normal and would bury the real misses.

---

Expand Down
95 changes: 95 additions & 0 deletions src/__tests__/siteImport/assetPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,98 @@ describe('buildAssetPlan — anchor hrefs to HTML pages do not produce assets',
expect(assets.every((a) => a.sourcePath !== 'about.html')).toBe(true)
})
})

// ---------------------------------------------------------------------------
// Unresolved references
// ---------------------------------------------------------------------------

describe('buildAssetPlan — references the archive cannot satisfy', () => {
it('matches a file whose stored name differs only in punctuation', () => {
// Real Webflow export: the file keeps `&`, `+`, and a space; the HTML
// written against it does not. Without the fallback the page imports with
// a broken image and nothing says why.
const stored = 'images/101-&Berlin-Office-Us+ Coworking.webp'
const referenced = 'images/101-Berlin-Office-Us-Coworking.webp'
const fileMap = makeFileMap({
'index.html': { bytes: txt(`<html><body><img src="${referenced}"></body></html>`), mimeType: 'text/html' },
[stored]: { bytes: MINIMAL_PNG, mimeType: 'image/webp' },
})
const src = new TextDecoder().decode(fileMap.files['index.html']!.bytes)
const { pagePlan } = makeHtmlPagePlan('index.html', src, fileMap)
const { normalizedPagePlans, assets, warnings } = buildAssetPlan([pagePlan], [], fileMap)

const nodes = Object.values(normalizedPagePlans[0].nodeFragment.nodes)
const imageNode = nodes.find((n) => typeof n.props['src'] === 'string')
expect(imageNode?.props['src']).toBe(stored)
expect(assets.map((a) => a.sourcePath)).toEqual([stored])
expect(warnings).toHaveLength(0)
})

it('refuses to guess when two files differ only in punctuation', () => {
// Picking one would silently put the wrong image on the page.
const fileMap = makeFileMap({
'index.html': { bytes: txt('<html><body><img src="images/a-b.png"></body></html>'), mimeType: 'text/html' },
'images/a&b.png': { bytes: MINIMAL_PNG, mimeType: 'image/png' },
'images/a+b.png': { bytes: MINIMAL_PNG, mimeType: 'image/png' },
})
const src = new TextDecoder().decode(fileMap.files['index.html']!.bytes)
const { pagePlan } = makeHtmlPagePlan('index.html', src, fileMap)
const { normalizedPagePlans, warnings } = buildAssetPlan([pagePlan], [], fileMap)

const nodes = Object.values(normalizedPagePlans[0].nodeFragment.nodes)
const imageNode = nodes.find((n) => typeof n.props['src'] === 'string')
expect(imageNode?.props['src']).toBe('images/a-b.png')
expect(warnings.map((w) => w.kind)).toEqual(['unresolved-asset'])
})

it('warns once per missing image, however many pages reference it', () => {
const html = '<html><body><img src="images/gone.png"></body></html>'
const fileMap = makeFileMap({
'index.html': { bytes: txt(html), mimeType: 'text/html' },
'about.html': { bytes: txt(html), mimeType: 'text/html' },
})
const plans = ['index.html', 'about.html'].map(
(path) => makeHtmlPagePlan(path, new TextDecoder().decode(fileMap.files[path]!.bytes), fileMap).pagePlan,
)
const { warnings } = buildAssetPlan(plans, [], fileMap)

expect(warnings).toHaveLength(1)
expect(warnings[0]?.kind).toBe('unresolved-asset')
expect(warnings[0]?.path).toBe('images/gone.png')
expect(warnings[0]?.message).toContain('images/gone.png')
})

it('reports a missing CSS background image too', () => {
const css = '.hero{background-image:url("../images/missing.jpg")}'
const fileMap = makeFileMap({
'styles/site.css': { bytes: txt(css), mimeType: 'text/css' },
})
const parsed = cssToStyleRules(css)
const cssFileResults: CssFileResult[] = [
{ cssPath: 'styles/site.css', rules: parsed.rules, assetRefs: parsed.assetRefs },
]
const { warnings } = buildAssetPlan([], cssFileResults, fileMap)

expect(warnings.map((w) => w.path)).toEqual(['images/missing.jpg'])
})

it('stays quiet about links, routes, and scripts that are not in the archive', () => {
// An anchor to an extensionless route, a page that lives elsewhere, and a
// CDN script are all normal. Warning about them would bury the real
// missing images.
const fileMap = makeFileMap({
'index.html': {
bytes: txt(
'<html><body><a href="/contact">Contact</a><a href="missing-page.html">Gone</a><img src="logo.png"></body></html>',
),
mimeType: 'text/html',
},
'logo.png': { bytes: MINIMAL_PNG, mimeType: 'image/png' },
})
const src = new TextDecoder().decode(fileMap.files['index.html']!.bytes)
const { pagePlan } = makeHtmlPagePlan('index.html', src, fileMap)
const { warnings } = buildAssetPlan([pagePlan], [], fileMap)

expect(warnings.filter((w) => w.kind === 'unresolved-asset')).toHaveLength(0)
})
})
29 changes: 27 additions & 2 deletions src/admin/modals/SiteImport/steps/ImportStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { HeadingIcon } from 'pixel-art-icons/icons/heading'
import { CodeIcon } from 'pixel-art-icons/icons/code'
import { CheckIcon } from 'pixel-art-icons/icons/check'
import { WarningDiamondSolidIcon } from 'pixel-art-icons/icons/warning-diamond-solid'
import type { ImportResult } from '@core/siteImport'
import type { ImportResult, ImportWarning } from '@core/siteImport'
import type { ImportResult as CmsImportResult } from '@core/data/bundleSchema'
import { ImportStepper } from '../shared/ImportStepper'
import { withSiteImportCategoryTints } from '../shared/importCategoryAccent'
Expand Down Expand Up @@ -248,7 +248,12 @@ function ImportLog({ result, droppedAtRules }: { result: ImportResult; droppedAt
if (result.scripts.length > 0) counts.push(`${result.scripts.length} ${plural(result.scripts.length, 'script')} imported`)
if (droppedAtRules > 0) counts.push(`${droppedAtRules} @-${plural(droppedAtRules, 'rule')} dropped`)

const warnings = result.warnings
// Only the first 12 warnings are shown, and a stylesheet-heavy import can
// produce dozens of cosmetic CSS notes — so the ones naming a specific file
// the user has to go fetch lead, ahead of the ones that are FYI.
const warnings = [...result.warnings].sort(
(a, b) => rankWarning(a.kind) - rankWarning(b.kind),
)

return (
<section className={styles.log} aria-label="Import log">
Expand Down Expand Up @@ -344,3 +349,23 @@ function categoryCount(categories: RunProgress['categories'], id: ImportCategory
function plural(n: number, word: string): string {
return n === 1 ? word : `${word}s`
}

/**
* Display order for the import log's warning list: something is missing from
* the site → something needs re-adding by hand → everything else, which is a
* note about how a CSS declaration was interpreted.
*/
function rankWarning(kind: ImportWarning['kind']): number {
switch (kind) {
case 'unresolved-asset':
case 'asset-upload-failed':
case 'missing-stylesheet':
case 'missing-script':
return 0
case 'external-font':
case 'font-install-failed':
return 1
default:
return 2
}
}
Loading