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