From f1e8376bf405a06d0a347e9dac5d09716ad1a71a Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:50:36 +0200 Subject: [PATCH] fix(site-import): report asset references the archive cannot satisfy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `` or CSS `url()` pointing at a file the archive does not contain resolved to null and was silently left alone — the page imported with a broken image and nothing in the import log said so. The user found out by opening the published site. Two changes: - On an exact-key miss, compare the path punctuation-insensitively against the archive. Exporters do not always agree with themselves about filenames: a Webflow export stores `101-&Berlin-Office-Us+ Coworking.webp` and references it from the HTML as `101-Berlin-Office-Us-Coworking.webp`. The match must be UNIQUE — two files differing only in punctuation are two different files, and guessing would put the wrong image on the page. - Whatever still does not resolve emits an `unresolved-asset` warning naming the path, once per path however many pages reference it. 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. The import log shows the first 12 warnings, so it now orders the kinds that name a missing file ahead of the CSS interpretation notes. The four URL normalisers all needed the same four things — file map, asset map, warnings, and the new lookup index — so they take one resolver instead of passing the pieces around individually. Co-Authored-By: Claude Fable 5 --- docs/features/site-import.md | 22 +++ src/__tests__/siteImport/assetPlan.test.ts | 95 ++++++++++ .../modals/SiteImport/steps/ImportStep.tsx | 29 ++- src/core/siteImport/assetPlan.ts | 175 ++++++++++++------ src/core/siteImport/index.ts | 1 + src/core/siteImport/types.ts | 6 + 6 files changed, 271 insertions(+), 57 deletions(-) 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