Generated banner for Portable Text Conversion
Generated logo for Portable Text Conversion

Portable Text Conversion

SanityOfficialCreated by sanity

  • Skill
  • Tool integration
  • Software development
  • MIT
  • No issues found

From the author

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.
— the description by sanity

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides methods and specifications for transforming external content formats like HTML and Markdown into the Portable Text format used by Sanity. It covers the use of specific libraries for automated conversion, as well as the structural requirements for manually constructing Portable Text blocks, ensuring developers can programmatically ingest or migrate content into their Sanity projects.

What it does

  • Converts Markdown to Portable Text using the markdownToPortableText function.
  • Parses HTML into Portable Text blocks using the htmlToBlocks utility.
  • Provides the structural specification for manual Portable Text block construction.
  • Explains the use of _key, _type, and markDefs within the Portable Text schema.
  • Details how to handle lists and nested content structures.

When to use it

  • When migrating content from a legacy CMS to Sanity.
  • When building automated pipelines to ingest HTML or Markdown into a Sanity dataset.
  • When programmatically generating rich text content from external APIs or databases.
  • When you need to ensure custom content structures adhere to the Portable Text specification.

Advantages

  • Standardizes content ingestion across different source formats.
  • Provides clear paths for both automated and manual content conversion.
  • Ensures compatibility with Sanity's internal data requirements.
  • Reduces manual effort during large-scale content migrations.

Usage examples

“How do I convert a Markdown string to Portable Text?”
The agent provides the implementation steps using the markdownToPortableText function from the @portabletext/markdown library.
“What is the structure of a Portable Text block?”
The agent returns the JSON schema for a block, including required fields like _type, _key, and the children array.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/sanity-io/portable-text-conversion/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip portable-text-conversion.zip -d .agents/skills

Documentation by sanity

Portable Text Conversion

Convert external content (HTML, Markdown) into Portable Text for Sanity. Three main approaches:

  1. markdownToPortableText — Convert Markdown directly using @portabletext/markdown (recommended for Markdown)

  2. htmlToBlocks — Parse HTML into PT blocks using @portabletext/block-tools (for HTML migration)

  3. Manual construction — Build PT blocks directly from any source (APIs, databases, etc.)

Portable Text Specification

Understand the target format before converting. PT is an array of blocks:

[
  {
    "_type": "block",
    "_key": "abc123",
    "style": "normal",
    "children": [
      {"_type": "span", "_key": "def456", "text": "Hello ", "marks": []},
      {"_type": "span", "_key": "ghi789", "text": "world", "marks": ["strong"]}
    ],
    "markDefs": []
  },
  {
    "_type": "block",
    "_key": "jkl012",
    "style": "h2",
    "children": [
      {"_type": "span", "_key": "mno345", "text": "A heading", "marks": []}
    ],
    "markDefs": []
  },
  {
    "_type": "image",
    "_key": "pqr678",
    "asset": {"_type": "reference", "_ref": "image-abc-200x200-png"}
  }
]

Key rules:

  • Every block and span needs _key (unique within the array)

  • _type: "block" is for text blocks; custom types use their own _type

  • markDefs holds annotation data; marks on spans reference markDefs[*]._key or are decorator strings

  • Lists use listItem ("bullet" | "number") and level (1, 2, 3...) on regular blocks

Conversion Rules

Read the rule file matching your source format:

  • Markdown → Portable Text: rules/markdown-to-pt.md — @portabletext/markdown with markdownToPortableText (recommended)

  • HTML → Portable Text: rules/html-to-pt.md — @portabletext/block-tools with htmlToBlocks

  • Manual PT Construction: rules/manual-construction.md — build blocks programmatically from any source

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

Files

rules/html-to-pt.md


title: Convert HTML to Portable Text description: Use @portabletext/block-tools with htmlToBlocks to convert HTML content into Portable Text blocks tags: [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

        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

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
sanity
Publisher
Sanity (Official)
Source
sanity-io/agent-toolkit · skills/portable-text-conversion · commit 3ed3a14
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by sanity.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download portable-text-conversion.zip