diff --git a/docs/features/site-import.md b/docs/features/site-import.md
index f6f28c9d8..223a51ce0 100644
--- a/docs/features/site-import.md
+++ b/docs/features/site-import.md
@@ -350,6 +350,28 @@ On success the same step switches to its **complete** state — a success mark,
| `missing-stylesheet` | A `` 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.
---
diff --git a/src/__tests__/siteImport/assetPlan.test.ts b/src/__tests__/siteImport/assetPlan.test.ts
index 1a42c6250..17710ffeb 100644
--- a/src/__tests__/siteImport/assetPlan.test.ts
+++ b/src/__tests__/siteImport/assetPlan.test.ts
@@ -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(`
`), 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(''), 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 = ''
+ 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(
+ 'ContactGone',
+ ),
+ 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)
+ })
+})
diff --git a/src/admin/modals/SiteImport/steps/ImportStep.tsx b/src/admin/modals/SiteImport/steps/ImportStep.tsx
index d32e18731..fc01d4eea 100644
--- a/src/admin/modals/SiteImport/steps/ImportStep.tsx
+++ b/src/admin/modals/SiteImport/steps/ImportStep.tsx
@@ -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'
@@ -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 (
@@ -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
+ }
+}
diff --git a/src/core/siteImport/assetPlan.ts b/src/core/siteImport/assetPlan.ts
index d57e85213..596b737f1 100644
--- a/src/core/siteImport/assetPlan.ts
+++ b/src/core/siteImport/assetPlan.ts
@@ -95,6 +95,26 @@ interface AssetPlanResult {
warnings: ImportWarning[]
}
+/**
+ * Everything URL normalisation needs, gathered once per import.
+ *
+ * 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.
+ */
+interface AssetResolver {
+ fileMap: FileMap
+ /** Deduplicated assets to upload, keyed by FileMap key. */
+ assetMap: Map
+ warnings: ImportWarning[]
+ /** Lazily built by `normalizedIndex` — see the note there. */
+ byNormalizedPath?: ReadonlyMap
+ /** One `unresolved-asset` warning per path, however many pages reference it. */
+ reportedMissing: Set
+}
+
/** Format preference when an `@font-face` lists several fallback files. */
const FONT_FORMAT_RANK: Record = {
woff2: 0,
@@ -133,15 +153,11 @@ export function buildAssetPlan(
const warnings: ImportWarning[] = []
/** Deduplicated assets by FileMap key. */
const assetMap = new Map()
+ const resolver: AssetResolver = { fileMap, assetMap, warnings, reportedMissing: new Set() }
// --- Normalise node fragments ---
const normalizedPagePlans: PagePlan[] = pagePlans.map((plan) => {
- const normalizedFragment = normalizeFragment(
- plan.nodeFragment,
- plan.source,
- fileMap,
- assetMap,
- )
+ const normalizedFragment = normalizeFragment(plan.nodeFragment, plan.source, resolver)
return { ...plan, nodeFragment: normalizedFragment }
})
@@ -149,7 +165,7 @@ export function buildAssetPlan(
const normalizedStyleRules: NewStyleRule[] = []
const styleRuleSources: string[] = []
for (const { cssPath, rules, assetRefs } of cssFileResults) {
- const normalized = normalizeRules(rules, assetRefs, cssPath, fileMap, assetMap)
+ const normalized = normalizeRules(rules, assetRefs, cssPath, resolver)
normalizedStyleRules.push(...normalized)
for (let i = 0; i < normalized.length; i++) styleRuleSources.push(cssPath)
}
@@ -165,7 +181,7 @@ export function buildAssetPlan(
priority: sheet.priority,
content: sheet.parts
.map((part) => {
- const normalized = normalizeRawCssUrls(part.cssText, part.cssPath, fileMap, assetMap)
+ const normalized = normalizeRawCssUrls(part.cssText, part.cssPath, resolver)
return sheet.parts.length > 1
? `/* ${part.cssPath.replace(/\*\//g, '*\\/')} */\n${normalized}`
: normalized
@@ -174,7 +190,7 @@ export function buildAssetPlan(
}))
// --- Resolve @font-face blocks into custom font families ---
- const fonts = buildFontFamilies(cssFileResults, fileMap, assetMap, warnings)
+ const fonts = buildFontFamilies(cssFileResults, resolver)
// --- Sweep up unreferenced media/font files ---
//
@@ -204,16 +220,11 @@ export function buildAssetPlan(
* `normalizeCssBag` for stylesheets kept as files. External URLs and
* unresolved paths pass through untouched.
*/
-function normalizeRawCssUrls(
- cssText: string,
- basePath: string,
- fileMap: FileMap,
- assetMap: Map,
-): string {
+function normalizeRawCssUrls(cssText: string, basePath: string, resolver: AssetResolver): string {
return cssText.replace(
/url\(\s*(['"]?)([^'")\n]+)\1\s*\)/g,
(match, _quote: string, rawUrl: string) => {
- const fileMapKey = resolveAndRecord(rawUrl.trim(), basePath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(rawUrl.trim(), basePath, resolver)
return fileMapKey ? `url('${fileMapKey}')` : match
},
)
@@ -235,9 +246,7 @@ function normalizeRawCssUrls(
*/
function buildFontFamilies(
cssFileResults: CssFileResult[],
- fileMap: FileMap,
- assetMap: Map,
- warnings: ImportWarning[],
+ resolver: AssetResolver,
): ImportFontFamily[] {
// family-lowercase → { display family, files, seen (variant) }
const byFamily = new Map }>()
@@ -249,7 +258,7 @@ function buildFontFamilies(
// Pick the best resolvable src among the face's fallback urls.
let best: { src: string; format: FontFileFormat } | null = null
for (const rawUrl of face.srcUrls) {
- const fileMapKey = resolveAndRecord(rawUrl, cssPath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(rawUrl, cssPath, resolver)
if (!fileMapKey) continue
const format = fontFormatForPath(fileMapKey)
if (!format) continue
@@ -259,7 +268,7 @@ function buildFontFamilies(
}
if (!best) {
- warnings.push({
+ resolver.warnings.push({
kind: 'external-font',
message: `@font-face "${face.family}" (${face.variant}) has no bundled font file — skipped. Re-add it via Typography → Upload custom font.`,
selector: face.family,
@@ -297,18 +306,17 @@ function buildFontFamilies(
function normalizeFragment(
fragment: ImportFragment,
htmlFilePath: string,
- fileMap: FileMap,
- assetMap: Map,
+ resolver: AssetResolver,
): ImportFragment {
const normalizedNodes: Record = {}
for (const [id, node] of Object.entries(fragment.nodes)) {
- const newProps = normalizeNodeProps(node.props, htmlFilePath, fileMap, assetMap)
+ const newProps = normalizeNodeProps(node.props, htmlFilePath, resolver)
// Inline background images live on `node.inlineStyles` as CSS `url(...)`
// payloads — normalise them to FileMap keys exactly like CSS-rule
// background values so `applyAssetRewrites` can swap in the media URL.
const newInlineStyles = node.inlineStyles
- ? normalizeCssBag(node.inlineStyles as Record, htmlFilePath, fileMap, assetMap)
+ ? normalizeCssBag(node.inlineStyles as Record, htmlFilePath, resolver)
: undefined
normalizedNodes[id] = {
...node,
@@ -321,10 +329,10 @@ function normalizeFragment(
? {
...fragment.body,
props: fragment.body.props
- ? normalizeNodeProps(fragment.body.props, htmlFilePath, fileMap, assetMap)
+ ? normalizeNodeProps(fragment.body.props, htmlFilePath, resolver)
: undefined,
inlineStyles: fragment.body.inlineStyles
- ? normalizeCssBag(fragment.body.inlineStyles, htmlFilePath, fileMap, assetMap)
+ ? normalizeCssBag(fragment.body.inlineStyles, htmlFilePath, resolver)
: undefined,
}
: undefined
@@ -340,8 +348,7 @@ function normalizeFragment(
function normalizeCssBag(
bag: Record,
basePath: string,
- fileMap: FileMap,
- assetMap: Map,
+ resolver: AssetResolver,
): Record {
const out: Record = { ...bag }
for (const [prop, val] of Object.entries(out)) {
@@ -349,7 +356,7 @@ function normalizeCssBag(
out[prop] = val.replace(
/url\(\s*(['"]?)([^'")\n]+)\1\s*\)/g,
(match, _quote: string, rawUrl: string) => {
- const fileMapKey = resolveAndRecord(rawUrl.trim(), basePath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(rawUrl.trim(), basePath, resolver)
return fileMapKey ? `url('${fileMapKey}')` : match
},
)
@@ -360,8 +367,7 @@ function normalizeCssBag(
function normalizeNodeProps(
props: Record,
htmlFilePath: string,
- fileMap: FileMap,
- assetMap: Map,
+ resolver: AssetResolver,
): Record {
const result: Record = { ...props }
@@ -370,11 +376,11 @@ function normalizeNodeProps(
if (typeof val !== 'string' || val.length === 0) continue
if (propKey === 'srcset') {
- result[propKey] = normalizeSrcset(val, htmlFilePath, fileMap, assetMap)
+ result[propKey] = normalizeSrcset(val, htmlFilePath, resolver)
continue
}
- const fileMapKey = resolveAndRecord(val, htmlFilePath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(val, htmlFilePath, resolver)
if (fileMapKey !== null) result[propKey] = fileMapKey
// If null: external URL or not in FileMap — leave original value
}
@@ -383,7 +389,7 @@ function normalizeNodeProps(
if (isStringRecord(htmlAttributes)) {
const normalizedAttrs: Record = { ...htmlAttributes }
for (const [attrName, attrValue] of Object.entries(normalizedAttrs)) {
- const fileMapKey = resolveAndRecord(attrValue, htmlFilePath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(attrValue, htmlFilePath, resolver)
if (fileMapKey !== null) normalizedAttrs[attrName] = fileMapKey
}
result['htmlAttributes'] = normalizedAttrs
@@ -397,17 +403,12 @@ function normalizeNodeProps(
* Format: `url1 2x, url2 1x` or `url1 800w, url2 1200w`.
* Only the URL parts are replaced; the descriptor (2x, 800w) is preserved.
*/
-function normalizeSrcset(
- srcset: string,
- htmlFilePath: string,
- fileMap: FileMap,
- assetMap: Map,
-): string {
+function normalizeSrcset(srcset: string, htmlFilePath: string, resolver: AssetResolver): string {
const parts = srcset.split(',').map((s) => s.trim()).filter(Boolean)
const normalized = parts.map((part) => {
const [urlPart, ...descriptors] = part.split(/\s+/)
if (!urlPart) return part
- const fileMapKey = resolveAndRecord(urlPart, htmlFilePath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(urlPart, htmlFilePath, resolver)
const url = fileMapKey ?? urlPart
return descriptors.length > 0 ? `${url} ${descriptors.join(' ')}` : url
})
@@ -422,8 +423,7 @@ function normalizeRules(
rules: NewStyleRule[],
assetRefs: AssetRef[],
cssFilePath: string,
- fileMap: FileMap,
- assetMap: Map,
+ resolver: AssetResolver,
): NewStyleRule[] {
if (assetRefs.length === 0) return rules
@@ -452,7 +452,7 @@ function normalizeRules(
}
for (const ref of refs) {
- const fileMapKey = resolveAndRecord(ref.rawUrl, cssFilePath, fileMap, assetMap)
+ const fileMapKey = resolveAndRecord(ref.rawUrl, cssFilePath, resolver)
if (fileMapKey === null) continue // external or not in FileMap
if (ref.rawCss === true) {
@@ -492,7 +492,7 @@ function normalizeRules(
for (const [ruleIdx, refs] of refsByRule) {
if (ruleIdx < normalized.length) continue
for (const ref of refs) {
- resolveAndRecord(ref.rawUrl, cssFilePath, fileMap, assetMap)
+ resolveAndRecord(ref.rawUrl, cssFilePath, resolver)
}
}
@@ -533,24 +533,24 @@ const NON_ASSET_MIME_PREFIXES: readonly string[] = [
*
* Returns null when:
* - the URL is external / uses a special scheme,
- * - the resolved path is not in the FileMap, or
+ * - the resolved path is not in the FileMap (a media reference that lands
+ * here also emits an `unresolved-asset` warning), or
* - the resolved file is a web document / script (HTML, CSS, JS), or any
* other MIME the CMS media endpoint cannot store.
*/
-function resolveAndRecord(
- rawUrl: string,
- basePath: string,
- fileMap: FileMap,
- assetMap: Map,
-): string | null {
+function resolveAndRecord(rawUrl: string, basePath: string, resolver: AssetResolver): string | null {
if (!rawUrl || EXTERNAL_URL_RE.test(rawUrl)) return null
- const fileMapKey = resolveRelativePath(rawUrl, basePath)
+ const resolvedPath = resolveRelativePath(rawUrl, basePath)
+ if (!resolvedPath) return null
+
+ const fileMapKey = resolveFileMapKey(resolvedPath, resolver)
if (!fileMapKey) return null
- const entry = fileMap.files[fileMapKey]
+ const entry = resolver.fileMap.files[fileMapKey]
if (!entry) return null
+ const { assetMap } = resolver
const mimeType = entry.mimeType ?? guessMimeType(fileMapKey)
// HTML, CSS, and JS files are page/style sources — never upload them as
@@ -570,6 +570,71 @@ function resolveAndRecord(
return fileMapKey
}
+/**
+ * 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.
+ *
+ * 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.
+ */
+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
+
+ // 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.
+ if (isImportUploadableMimeType(guessMimeType(resolvedPath)) && !resolver.reportedMissing.has(resolvedPath)) {
+ resolver.reportedMissing.add(resolvedPath)
+ resolver.warnings.push({
+ kind: 'unresolved-asset',
+ message: `"${resolvedPath}" is referenced but not in the archive — the element imports with a broken link. Re-export the site, or upload the file and repoint it.`,
+ path: resolvedPath,
+ })
+ }
+ return null
+}
+
+/**
+ * Index of every FileMap key by its punctuation-insensitive form. Keys that
+ * collide map to `null` — an ambiguous match is no match.
+ *
+ * Built on the first miss: an archive whose references all resolve exactly
+ * never pays for it.
+ */
+function normalizedIndex(resolver: AssetResolver): ReadonlyMap {
+ if (resolver.byNormalizedPath) return resolver.byNormalizedPath
+
+ const index = new Map()
+ for (const filePath of Object.keys(resolver.fileMap.files)) {
+ const key = normalizeAssetPath(filePath)
+ index.set(key, index.has(key) ? null : filePath)
+ }
+ resolver.byNormalizedPath = index
+ return index
+}
+
+/**
+ * Strip a path down to what survives an exporter's filename sanitising:
+ * lowercase, with everything but letters, digits, `.` and `/` removed. That
+ * folds `&`, `+`, spaces, and `-` together, which is exactly the set that
+ * differs between a stored filename and the reference written to it.
+ */
+function normalizeAssetPath(path: string): string {
+ return path.toLowerCase().replace(/[^a-z0-9./]+/g, '')
+}
+
/**
* Resolve a raw URL against a base file path to produce a FileMap key.
* Returns null for traversal-escaping paths or empty strings.
diff --git a/src/core/siteImport/index.ts b/src/core/siteImport/index.ts
index b4fe22130..bb81f9c21 100644
--- a/src/core/siteImport/index.ts
+++ b/src/core/siteImport/index.ts
@@ -79,6 +79,7 @@ export type {
CrossSheetClassConflict,
ImportPlan,
ImportResult,
+ ImportWarning,
StylesheetImportMode,
ImportStylesheet,
// @font-face import
diff --git a/src/core/siteImport/types.ts b/src/core/siteImport/types.ts
index 41af049d6..5c2803c6a 100644
--- a/src/core/siteImport/types.ts
+++ b/src/core/siteImport/types.ts
@@ -73,6 +73,11 @@ export type NewStyleRule = Omit
* (or `local(...)` only) — nothing to upload, so the face is skipped rather
* than imported. The user can re-add the font by hand. Self-hosted faces
* (a bundled `.woff2`/`.woff`/`.ttf`/`.otf`) ARE imported as custom fonts.
+ * - `unresolved-asset`: an HTML/CSS reference to a media file (image, font,
+ * video, …) that the archive does not contain under that path — not even
+ * after the punctuation-insensitive fallback match. The reference is left
+ * pointing at its original path, so the page imports with a broken image
+ * rather than losing the element. One warning per distinct missing path.
*/
type ImportWarningKind =
| 'dropped-at-rule'
@@ -87,6 +92,7 @@ type ImportWarningKind =
| 'asset-upload-failed'
| 'font-install-failed'
| 'external-font'
+ | 'unresolved-asset'
export interface ImportWarning {
kind: ImportWarningKind