Run the asset pre-upload pass
Assets must exist in Sanity before documents that reference them are created. Run this pass first, before any content migration.
The Webflow Assets API only returns files from the Site Assets panel. It misses images in CMS fields and rich text. Scan your saved API data and HTML export for all image URLs instead.
Images appear in three different places depending on how your Webflow site was built:
- Webflow CDN URLs — most images in CMS field data and rich text. Webflow uses multiple CDN domains depending on account age; scan all three:
uploads-ssl.webflow.com— most commonassets-global.website-files.com— older accountscdn.prod.website-files.com— newer accounts
- Local export paths — images referenced as relative paths in the HTML export (e.g.
images/product-hero.png). These exist atwebflow/export/images/and need to be uploaded from disk rather than fetched from a URL.
If asset-map.json ends up with fewer entries than you expect, or if you see No asset mapping for figure image warnings during import, run this diagnostic: grep your HTML export for all src= and href= attributes and compare the domains against what the scanner matched.
// scripts/pre-upload-assets.jsimport { client } from '../lib/sanity-client.js'import { glob } from 'fs/promises'import fs from 'fs'import path from 'path'
// All known Webflow CDN domains — check your HTML export if you're missing assetsconst CDN_PATTERNS = [ /https:\/\/uploads-ssl\.webflow\.com\/[^\s"'<>\\]+/g, /https:\/\/assets-global\.website-files\.com\/[^\s"'<>\\]+/g, /https:\/\/cdn\.prod\.website-files\.com\/[^\s"'<>\\]+/g,]
// Local export image paths: src="images/foo.png" → upload from webflow/export/images/foo.pngconst LOCAL_IMG_PATTERN = /(?:src|href)=["'](images\/[^"']+)["']/g
const WEBFLOW_CDN_PATTERN = CDN_PATTERNS[0] // kept for backwards compat in rich-text.js
function cleanUrl(url) { // Strip any trailing punctuation that leaked in from HTML attribute context return url.replace(/[\\,;]+$/, '')}
async function run() { const seen = new Set() const assetMap = {}
// Scan all NDJSON item files for CDN URLs in fieldData const ndjsonFiles = await Array.fromAsync(glob('webflow/api/items/*.ndjson')) for (const filePath of ndjsonFiles) { const content = fs.readFileSync(filePath, 'utf8') for (const pattern of CDN_PATTERNS) { for (const match of content.matchAll(new RegExp(pattern.source, 'g'))) seen.add(cleanUrl(match[0])) } }
// Scan HTML export files for CDN URLs and local relative paths const htmlFiles = await Array.fromAsync(glob('webflow/export/**/*.html')) const localPaths = new Set() // relative paths like "images/foo.png" for (const filePath of htmlFiles) { const content = fs.readFileSync(filePath, 'utf8') for (const pattern of CDN_PATTERNS) { for (const match of content.matchAll(new RegExp(pattern.source, 'g'))) seen.add(cleanUrl(match[0])) } // Collect local export image paths for (const match of content.matchAll(LOCAL_IMG_PATTERN)) localPaths.add(match[1]) }
console.log(`Found ${seen.size} CDN URLs + ${localPaths.size} local paths. Uploading...`)
// Upload CDN assets for (const url of seen) { try { const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`)
const contentType = response.headers.get('content-type') ?? 'application/octet-stream' const filename = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? 'asset') // SVGs and other non-raster files must be uploaded as 'file', not 'image' const assetType = /^image\/(jpeg|png|gif|webp|avif)/.test(contentType) ? 'image' : 'file'
const buffer = Buffer.from(await response.arrayBuffer()) const asset = await client.assets.upload(assetType, buffer, { filename, contentType, source: { name: 'webflow-migration', id: url } }) assetMap[url] = asset._id console.log(`✓ ${filename}`) } catch (err) { console.warn(`✗ ${url}: ${err.message}`) } }
// Upload local export images (e.g. images/product-hero.png) for (const relPath of localPaths) { const diskPath = path.join('webflow/export', relPath) if (!fs.existsSync(diskPath)) { console.warn(`✗ Local file not found: ${diskPath}`); continue } try { const buffer = fs.readFileSync(diskPath) const filename = path.basename(relPath) const asset = await client.assets.upload('image', buffer, { filename, source: { name: 'webflow-migration', id: relPath } }) // Map both the relative path and a root-relative URL form so rich-text.js can look it up assetMap[relPath] = asset._id assetMap[`/${relPath}`] = asset._id console.log(`✓ ${filename} (local)`) } catch (err) { console.warn(`✗ ${relPath}: ${err.message}`) } }
fs.writeFileSync('asset-map.json', JSON.stringify(assetMap, null, 2)) console.log(`Done. asset-map.json written with ${Object.keys(assetMap).length} entries.`) if (Object.keys(assetMap).length === 0) { console.warn('⚠ asset-map.json is empty — check CDN domains in CDN_PATTERNS against your HTML export.') }}
run().catch(err => { console.error(err.message); process.exit(1) })Run it from the project root:
node --env-file=.env scripts/pre-upload-assets.jsThe script logs each URL as it uploads (✓ for success, ✗ for failure) and writes asset-map.json when complete. It's safe to re-run. If interrupted, assets already uploaded are skipped on the next run because Sanity deduplicates by source ID.
Next, you’ll build the utility modules that your extraction scripts depend on.