Files
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

6.7 KiB

title, description, tags
title description tags
Convert HTML to Portable Text Use @portabletext/block-tools with htmlToBlocks to convert HTML content into Portable Text blocks
portable-text
html
conversion
migration
import

Convert HTML to Portable Text

Use @portabletext/block-tools to parse HTML into Portable Text blocks. This is the primary tool for migrating HTML content from legacy CMSs. It has built-in support for content from Google Docs, Microsoft Word, and Notion.

Note: For Markdown sources, use @portabletext/markdown instead — it's simpler and more direct. See rules/markdown-to-pt.md.

Note: @sanity/block-tools is the legacy package name. Use @portabletext/block-tools for new projects. The API is identical.

Setup

npm install @portabletext/block-tools jsdom @sanity/schema

In Node.js, you must provide a parseHtml function that returns a DOM Document. Use JSDOM for this:

import {htmlToBlocks} from '@portabletext/block-tools'
import {JSDOM} from 'jsdom'
import Schema from '@sanity/schema'

// JSDOM is passed to htmlToBlocks via the parseHtml option:
// htmlToBlocks(html, blockContentType, {
//   parseHtml: (html) => new JSDOM(html).window.document,
// })

Define Your Schema

htmlToBlocks needs a compiled Sanity block content type to know which marks, styles, and custom types are valid. Use @sanity/schema to compile it:

const defaultSchema = Schema.compile({
  name: 'mySchema',
  types: [
    {
      name: 'post',
      type: 'document',
      fields: [
        {
          name: 'body',
          type: 'array',
          of: [
            {
              type: 'block',
              marks: {
                decorators: [
                  {title: 'Strong', value: 'strong'},
                  {title: 'Emphasis', value: 'em'},
                  {title: 'Code', value: 'code'},
                ],
                annotations: [
                  {
                    name: 'link',
                    type: 'object',
                    fields: [{name: 'href', type: 'url'}],
                  },
                ],
              },
              styles: [
                {title: 'Normal', value: 'normal'},
                {title: 'H2', value: 'h2'},
                {title: 'H3', value: 'h3'},
                {title: 'Quote', value: 'blockquote'},
              ],
              lists: [
                {title: 'Bullet', value: 'bullet'},
                {title: 'Number', value: 'number'},
              ],
            },
            {
              name: 'image',
              type: 'image',
              fields: [{name: 'alt', type: 'string'}],
            },
          ],
        },
      ],
    },
  ],
})

const blockContentType = defaultSchema
  .get('post')
  .fields.find((f) => f.name === 'body').type

Basic Conversion

const html = '<p>Hello <strong>world</strong></p><h2>Heading</h2>'

const blocks = htmlToBlocks(html, blockContentType, {
  parseHtml: (html) => new JSDOM(html).window.document,
})

Custom Deserializers

Handle HTML elements that don't map directly to standard PT:

const blocks = htmlToBlocks(html, blockContentType, {
  parseHtml: (html) => new JSDOM(html).window.document,
  rules: [
    // Convert <img> to image blocks
    {
      deserialize(el, next, block) {
        if (el.tagName?.toLowerCase() !== 'img') return undefined

        return block({
          _type: 'image',
          asset: {
            _type: 'reference',
            _ref: '', // Upload image separately, set ref after
          },
          alt: el.getAttribute('alt') || '',
          _sanityAsset: `image@${el.getAttribute('src')}`, // for migration tooling
        })
      },
    },
    // Convert <a> with custom attributes
    {
      deserialize(el, next, block) {
        if (el.tagName?.toLowerCase() !== 'a') return undefined

        const href = el.getAttribute('href') || ''
        const target = el.getAttribute('target') || ''

        return {
          _type: '__annotation',
          markDef: {
            _type: 'link',
            href,
            ...(target ? {target} : {}),
          },
          children: next(el.childNodes),
        }
      },
    },
    // Convert <iframe> to embed blocks
    {
      deserialize(el, next, block) {
        if (el.tagName?.toLowerCase() !== 'iframe') return undefined

        return block({
          _type: 'embed',
          url: el.getAttribute('src') || '',
        })
      },
    },
  ],
})

Pre-Process HTML Before Conversion

Strip layout elements and extract metadata:

function preprocessHtml(rawHtml: string) {
  const dom = new JSDOM(rawHtml)
  const doc = dom.window.document

  // Remove layout elements
  const removeSelectors = ['header', 'footer', 'nav', '.sidebar', '.menu', 'script', 'style']
  removeSelectors.forEach((sel) => {
    doc.querySelectorAll(sel).forEach((el) => el.remove())
  })

  // Extract metadata
  const title = doc.querySelector('h1')?.textContent || doc.title || ''
  const description = doc.querySelector('meta[name="description"]')?.getAttribute('content') || ''

  // Get cleaned body
  const body = doc.querySelector('article')?.innerHTML || doc.body.innerHTML

  return {title, description, body}
}

Upload Images During Migration

Don't just link external images — upload them to Sanity:

import type {SanityClient} from '@sanity/client'

async function uploadImage(client: SanityClient, url: string) {
  const response = await fetch(url)
  const buffer = await response.arrayBuffer()
  const asset = await client.assets.upload('image', Buffer.from(buffer), {
    filename: url.split('/').pop(),
  })
  return {
    _type: 'image',
    asset: {_type: 'reference', _ref: asset._id},
  }
}

Full Migration Example

import {defineMigration, createOrReplace} from 'sanity/migrate'

export default defineMigration({
  title: 'Import WordPress posts',
  async *migrate(documents, context) {
    const posts = await fetchWordPressPosts()

    for (const post of posts) {
      const {title, description, body} = preprocessHtml(post.content)
      const blocks = htmlToBlocks(body, blockContentType, {
        parseHtml: (html) => new JSDOM(html).window.document,
        rules: [/* custom rules */],
      })

      yield createOrReplace({
        _id: `post-${post.slug}`,
        _type: 'post',
        title: title || post.title,
        body: blocks,
      })
    }
  },
})

Run with: sanity migration run import-wordpress-posts --no-dry-run

Reference