Convert rich text to Portable Text
Webflow rich text exports as HTML. Use htmlToBlocks() from @sanity/block-tools to convert it to Portable Text, with custom deserializer rules for the patterns specific to your export. Create this module before running the extraction script. extract-static-pages.js imports from it.
The default htmlToBlocks() handles standard paragraphs, headings, lists, and inline formatting. Anything else needs an explicit deserializer rule. Before prompting AI, classify what's in your export: figure/figcaption image wrappers map to a custom figureImage type, bare <img> tags map to the same or a standard image block, <pre><code> maps to a codeBlock type with a language field, embeds and iframes (w-embed) map to htmlEmbed, <table> needs a decision upfront since there's no native Portable Text table, and internal <a href="/internal-page"> links need an internalLink annotation resolved through url-map.json.
The most important one to decide early: <figure data-rt-type="image">. Webflow wraps rich text images in this pattern. If you don't add a custom deserializer rule for it, htmlToBlocks() either drops the image or dumps the entire <figure> as an htmlEmbed blob editable as raw HTML, not as a structured image with a caption field. Add the figureImage rule before your first import.
Verify after first import: open a document containing rich text in Studio and confirm you see figureImage blocks with image and caption fields, not htmlEmbed blocks containing a <figure> HTML string. If you see the latter, the deserializer rule is missing or not matching.
Note on local image paths in rich text: if the figureImage deserializer encounters src="images/foo.png" instead of a CDN URL, it won't find a match in asset-map.json. Make sure pre-upload-assets.js has scanned local export images and added both the relative path and root-relative form as keys in asset-map.json before running the import.
pnpm add @sanity/block-tools jsdom nanoidBefore asking AI to generate the converter, look at the raw HTML for your richest collection content. The NDJSON files you fetched in Chapter 2 contain the actual field values — including the raw HTML for any rich text fields. Ask AI to find and print a sample for you:
@webflow/api/items/ Find the collection NDJSON file with the most items. Read the first few items and find a field whose value starts with an HTML tag. Print the full HTML value of that field so I can inspect it.Read the output and look for anything that doesn't follow standard HTML semantics. Common Webflow-specific patterns to watch for:
- Italic used as code:
<em>someCode()</em>where the intent is inline code, not emphasis. This happens when editors use the Webflow italic button to style code because there's no code button in the Webflow rich text toolbar. - Code blocks with language labels baked in: blocks starting with plain text like "HTML", "CSS", or "JavaScript" before the code content, or wrapped in custom class divs.
- Tables inside rich text:
<table>elements embedded in the HTML that need either code-block treatment or a Portable Text table plugin. - Custom styled spans:
<span class="some-custom-class">used for highlighting, callouts, or other semantic purposes. - Nested lists with inconsistent structure: Webflow's rich text editor sometimes produces non-standard list nesting.
- Images wrapped in
<figure>: Webflow exports rich text images as<figure data-rt-type="image">elements, not bare<img>tags. The structure is:<figure data-rt-type="image"><div><img src="..." alt="..."></div><figcaption>Caption text</figcaption></figure>. An image that was also hyperlinked has an additional<a>layer:<figure><a href="..."><div><img></div></a><figcaption>...</figcaption></figure>. A bare<img>rule won't match these — you need an explicit<figure>rule that extracts the img, figcaption, and optional href.
Write down what you find. These become explicit instructions in the AI prompt below. Without them, AI generates generic rules that silently drop your site-specific formatting.
@webflow/export/index.html @webflow/api/items/[your-largest-collection-id].ndjson @sanity/schemaTypes/blockContent.ts
I need a scripts/rich-text.js module that exports a convertRichText(html) function using htmlToBlocks from @sanity/block-tools and JSDOM. Looking at the HTML patterns in the export and NDJSON files, write custom deserializer rules for:
- Images: replace Webflow CDN URLs with Sanity asset references from asset-map.json- HTML embeds: Webflow wraps these in <div class="w-embed">- [PASTE YOUR SITE-SPECIFIC PATTERNS HERE] — for example: - <em> elements that represent inline code rather than emphasis: convert to code marks instead of em marks - Code blocks that start with a language label (HTML, CSS, JavaScript) followed by the code: strip the label, detect the language, and emit a code block type with the language field set - <table> elements inside rich text: convert to a code block showing the HTML source, or describe how your blockContent schema handles tables if you have a table plugin installed
Also export a resolveInternalLinks(blocks, urlMap) helper that: leaves external URLs, mailto links, and anchor links unchanged; for internal hrefs found in url-map.json, converts the link mark type to an internalLink mark type with reference: { _type: 'reference', _ref: sanityId } and remaps the child mark key; falls back to the original href for unmatched internal paths. Load url-map.json gracefully — warn and use {} if the file doesn't exist. Use nanoid to generate new mark keys when converting link types.Replace the bracketed section with the actual patterns you found in Step 1. The more specific you are about your site's quirks, the less manual cleanup you'll do in Studio after migration.
A note on tables in rich text: if your content has <table> elements inside rich text fields and you want them to be editable tables in Sanity (not just code blocks), install a Portable Text table plugin first and describe its block type to AI before generating this file. If you don't have a plugin, the safest fallback is to convert tables to htmlEmbed blocks so the HTML is preserved and editors can see it, even if they can't edit it as a table.
Pointing at your largest collection's NDJSON gives AI real rich text HTML to analyze — it can see the actual embed patterns, image URL formats, and link structures in your content rather than guessing.
// scripts/rich-text.jsimport { htmlToBlocks } from '@sanity/block-tools'import { JSDOM } from 'jsdom'import fs from 'fs'import { nanoid } from 'nanoid'
const assetMap = JSON.parse(fs.readFileSync('asset-map.json', 'utf8'))
// Exported so import.js can use the same map without loading asset-map.json twice.// Returns the Sanity asset _id for a given Webflow CDN URL, or null if not found.export function lookupAssetId(url) { return assetMap[url] ?? null}
// url-map.json maps Webflow URL paths → Sanity document _ids// Missing file is handled gracefully — internal links stay as plain linksconst urlMap = fs.existsSync('url-map.json') ? JSON.parse(fs.readFileSync('url-map.json', 'utf8')) : (() => { console.warn('Warning: url-map.json not found — using {}'); return {} })()
export function convertRichText(html, blockContentType) { const blocks = htmlToBlocks(html, blockContentType, { parseHtml: html => new JSDOM(html).window.document, rules: [ { deserialize(el, next, block) { if (el.tagName?.toLowerCase() !== 'img') return undefined const src = el.getAttribute('src') ?? '' const sanityId = assetMap[src] if (!sanityId) { console.warn(`No asset mapping for: ${src}`) return undefined } return block({ _type: 'image', asset: { _type: 'reference', _ref: sanityId }, alt: el.getAttribute('alt') ?? '' }) } }, { deserialize(el, next, block) { if (el.tagName?.toLowerCase() === 'div' && el.classList.contains('w-embed')) { return block({ _type: 'htmlEmbed', html: el.innerHTML }) } return undefined } } ] }) return resolveInternalLinks(blocks, urlMap)}
export function resolveInternalLinks(blocks, urlMap) { return blocks.map(block => { if (block._type !== 'block' || !block.markDefs?.length) return block
const newMarkDefs = [] const markMap = {} // old _key → new _key (when a link becomes an internalLink)
for (const def of block.markDefs) { if (def._type !== 'link') { newMarkDefs.push(def); continue } const href = def.href ?? ''
// Leave external, mailto, and anchor links unchanged if (href.startsWith('http') || href.startsWith('mailto') || href.startsWith('#')) { newMarkDefs.push(def) continue }
const sanityId = urlMap[href] if (sanityId) { // Convert to an internalLink reference annotation const newKey = nanoid() markMap[def._key] = newKey newMarkDefs.push({ _key: newKey, _type: 'internalLink', reference: { _type: 'reference', _ref: sanityId } }) } else { // No mapping found — leave as a plain link with the original href newMarkDefs.push(def) } }
// Remap any mark keys in the block's children that were changed above const newChildren = block.children.map(child => { if (!child.marks?.length) return child return { ...child, marks: child.marks.map(key => markMap[key] ?? key) } })
return { ...block, markDefs: newMarkDefs, children: newChildren } })}Three things to check before moving on:
- Portable Text images are always block-level, so inline images from Webflow become full-width blocks. Account for this in visual QA.
- Add
htmlEmbedto your blockContent schema before running this if it isn't already there. - If you use a
figureImagecustom block type for Webflow's figure/figcaption pattern, define it as a named object type in your schema and register it in schemaTypes. See the gotcha in the schemas lesson about blockContent object type registration. InblockContent.ts, include{ type: 'figureImage' }in the array'soflist, replace{ type: 'image' }with it, and also write the figureImage deserializer rule before the bare<img>rule so it takes precedence. The<figure>rule should: findel.querySelector('img')for src and alt,el.querySelector('figcaption')for caption text,el.querySelector('a[href]')for an optional link href, look up the asset ID, and emit a block of the shape{ _type: 'figureImage', image: { _type: 'image', asset: { _ref: assetId } }, alt, caption, href }.
Next, you’ll extract static page content using the page DOM API.