All skills
sanity-io avatar

/portable-text-conversion

@a11c399 official
by Sanitysanity-io/agent-toolkit187 stars
30

Convert HTML and Markdown content into Portable Text blocks for Sanity. Use when migrating content from legacy CMSs, importing HTML or Markdown into Sanity, building content pipelines that ingest external content, converting rich text between formats, or programmatically creating Portable Text documents. Covers @portabletext/markdown (markdownToPortableText), @portabletext/block-tools (htmlToBlocks), custom deserializers, and the Portable Text specification for manual block construction.

Use this Skill: https://skilld.dev/gh/sanity-io/agent-toolkit/portable-text-conversion

This session only. Nothing lands on disk.

ruleshtml-to-pt.md

≈1.8k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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

        const src = el.getAttribute('src')
        if (!src) return undefined // skip sourceless images, not `image@null`

        return block({
          _type: 'image',
          asset: {
            _type: 'reference',
            _ref: '', // Upload image separately, set ref after
          },
          alt: el.getAttribute('alt') || '',
          // Resolved by `sanity datasets import` only. On client/mutation-API
          // write paths, upload the asset first and set `asset._ref` instead.
          _sanityAsset: `image@${src}`,
        })
      },
    },
    // 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 migrations run import-wordpress-posts --no-dry-run

Reference

Source: SKILL.md on GitHub

No alerts16d4 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    This skill provides documentation and code examples for converting HTML and Markdown content into Sanity's Portable Text format using official and well-known libraries.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at a11c399. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 8 months ago
metadata
{
  "author": "sanity",
  "version": "1.0.0"
}
  • sanity
  • portable-text
  • html
  • markdown
  • content-migration
  • cms
  • portabletext
  • block-tools

README badge

README badge for sanity-io/agent-toolkit/portable-text-conversion

Converts HTML and Markdown content into Portable Text blocks for Sanity using @portabletext/markdown and @portabletext/block-tools. Useful for migrating content from legacy CMSs, importing external rich text into Sanity, or programmatically building Portable Text documents from various sources.

Generated from the current SKILL.md.

Does this skill work with HTML and Markdown, or just one format?
It covers both. Use @portabletext/markdown for Markdown sources, @portabletext/block-tools for HTML sources, or manually construct blocks from any other format.
What is Portable Text?
Portable Text is Sanity's JSON-based rich text format. It represents content as an array of typed blocks (text blocks, images, custom types) with annotations, marks, and nested structures.
Can I convert from APIs or databases, not just HTML/Markdown?
Yes. The skill includes manual block construction rules for building Portable Text blocks programmatically from any data source.
Is @sanity/block-tools the correct package to use?
No. Use @portabletext/block-tools instead; @sanity/block-tools is the legacy package name. The API is the same.

Generated from the current SKILL.md. These answers refresh after source changes.