
Portable Text Conversion
- 1.5k installs
- 171 repo stars
- Updated July 29, 2026
- sanity-io/agent-toolkit
Portable Text Conversion is a backend integration toolkit that transforms HTML and Markdown content into Sanity's Portable Text block format for content pipelines and migrations.
About
Portable Text Conversion toolkit enables developers to transform HTML and Markdown content into Sanity's Portable Text format for content migration, pipeline ingestion, and programmatic document creation. Provides three conversion approaches: markdownToPortableText via @portabletext/markdown (recommended for Markdown), htmlToBlocks via @portabletext/block-tools (for HTML migration), and manual block construction from APIs or databases. Covers Portable Text specification including block structure, span annotations, mark definitions, and list handling with proper key generation and type assignment.
- Three conversion methods: markdownToPortableText, htmlToBlocks, and manual programmatic construction
- Comprehensive Portable Text specification documentation with JSON structure, key rules, and annotation patterns
- Supports content migration from legacy CMSs and external data sources via block-tools and markdown packages
- Convert HTML and Markdown content into Portable Text blocks for content migration and pipeline ingestion
- Convert HTML and Markdown content into Portable Text blocks for content migration and pipeline ingestion
Portable Text Conversion by the numbers
- 1,451 all-time installs (skills.sh)
- +90 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #328 of 4,348 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
portable-text-conversion capabilities & compatibility
- Capabilities
- markdown to portable text · html to portable text · block construction · content migration · format conversion · annotation mapping
- Use cases
- api development · data analysis · refactoring
- Runs
- Runs locally
- Pricing
- Free
What portable-text-conversion says it does
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
npx skills add https://github.com/sanity-io/agent-toolkit --skill portable-text-conversionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 171 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | sanity-io/agent-toolkit ↗ |
What it does
Convert HTML and Markdown content into Portable Text blocks for content migration and pipeline ingestion
Who is it for?
Migrating content from legacy CMSs, importing HTML or Markdown into Sanity, building content pipelines that ingest external content, converting rich text between formats, programmatically creating Portable Text documents
Skip if: Real-time streaming content transformation at scale, client-side rich text editing, non-structured content formats
When should I use this skill?
You need to ingest external HTML or Markdown content into Sanity, migrate legacy CMS content, or build automated content pipelines with format conversion
What you get
Content is successfully converted from external formats into standardized Portable Text blocks ready for Sanity ingestion and storage
- Portable Text block JSON
- migration-ready content arrays
By the numbers
- Three main conversion approaches: markdownToPortableText, htmlToBlocks, and manual construction
- PT blocks contain required properties: _type, _key, and format-specific fields like style, children, markDefs
- Supports hierarchical list nesting via level property (1, 2, 3+) on block listItem types
Files
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_typemarkDefsholds annotation data;markson spans referencemarkDefs[*]._keyor are decorator strings- Lists use
listItem("bullet" | "number") andlevel(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/markdownwithmarkdownToPortableText(recommended) - HTML → Portable Text:
rules/html-to-pt.md—@portabletext/block-toolswithhtmlToBlocks - Manual PT Construction:
rules/manual-construction.md— build blocks programmatically from any source
Note:@sanity/block-toolsis the legacy package name. Always use@portabletext/block-toolsfor new projects. The API is the same.
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/markdowninstead — it's simpler and more direct. Seerules/markdown-to-pt.md.
Note:@sanity/block-toolsis the legacy package name. Use@portabletext/block-toolsfor new projects. The API is identical.
Setup
npm install @portabletext/block-tools jsdom @sanity/schemaIn 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').typeBasic 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
- @portabletext/block-tools — part of the
portabletext/editormonorepo - Sanity Migration docs
- portabletext.org — Editor docs and serializer list
Manually Construct Portable Text Blocks
Build PT blocks directly when converting from non-HTML sources (APIs, databases, custom formats) or when you need precise control over the output.
Key Generation
Every block, span, and markDef needs a unique _key:
import {randomKey} from '@sanity/util/content'
const key = randomKey(12) // e.g., "a1b2c3d4e5f6"Or use a simple helper:
const randomKey = () => Math.random().toString(36).slice(2, 14)Building Blocks
Simple Paragraph
{
_type: 'block',
_key: randomKey(),
style: 'normal',
children: [
{_type: 'span', _key: randomKey(), text: 'Hello world', marks: []}
],
markDefs: []
}Heading
{
_type: 'block',
_key: randomKey(),
style: 'h2', // h1, h2, h3, h4, h5, h6
children: [
{_type: 'span', _key: randomKey(), text: 'Section Title', marks: []}
],
markDefs: []
}Text with Decorators (Bold, Italic, Code)
{
_type: 'block',
_key: randomKey(),
style: 'normal',
children: [
{_type: 'span', _key: randomKey(), text: 'This is ', marks: []},
{_type: 'span', _key: randomKey(), text: 'bold', marks: ['strong']},
{_type: 'span', _key: randomKey(), text: ' and ', marks: []},
{_type: 'span', _key: randomKey(), text: 'italic', marks: ['em']},
{_type: 'span', _key: randomKey(), text: ' text.', marks: []},
],
markDefs: []
}Text with Annotations (Links)
Annotations require a markDef entry and a matching key in marks:
const linkKey = randomKey()
{
_type: 'block',
_key: randomKey(),
style: 'normal',
children: [
{_type: 'span', _key: randomKey(), text: 'Visit ', marks: []},
{_type: 'span', _key: randomKey(), text: 'Sanity', marks: [linkKey]},
{_type: 'span', _key: randomKey(), text: ' for more.', marks: []},
],
markDefs: [
{_type: 'link', _key: linkKey, href: 'https://www.sanity.io'}
]
}Overlapping Marks
A span can have multiple marks (both decorators and annotations):
const linkKey = randomKey()
// "bold link" — both strong and linked
{_type: 'span', _key: randomKey(), text: 'bold link', marks: ['strong', linkKey]}Lists
Lists are regular blocks with listItem and level:
// Bullet list
[
{
_type: 'block', _key: randomKey(), style: 'normal',
listItem: 'bullet', level: 1,
children: [{_type: 'span', _key: randomKey(), text: 'First item', marks: []}],
markDefs: []
},
{
_type: 'block', _key: randomKey(), style: 'normal',
listItem: 'bullet', level: 1,
children: [{_type: 'span', _key: randomKey(), text: 'Second item', marks: []}],
markDefs: []
},
{
_type: 'block', _key: randomKey(), style: 'normal',
listItem: 'bullet', level: 2, // nested
children: [{_type: 'span', _key: randomKey(), text: 'Nested item', marks: []}],
markDefs: []
},
]Custom Block Types
Any object with _type and _key can be a block:
// Image block
{
_type: 'image',
_key: randomKey(),
asset: {_type: 'reference', _ref: 'image-abc123-800x600-png'},
alt: 'A description',
}
// Code block
{
_type: 'code',
_key: randomKey(),
language: 'typescript',
code: 'const x = 42',
}
// YouTube embed
{
_type: 'youtube',
_key: randomKey(),
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
}Helper Function
A utility for building common blocks:
function createBlock(
text: string,
style: string = 'normal',
options?: {listItem?: string; level?: number}
) {
return {
_type: 'block',
_key: randomKey(),
style,
...(options?.listItem ? {listItem: options.listItem, level: options.level || 1} : {}),
children: [{_type: 'span', _key: randomKey(), text, marks: []}],
markDefs: [],
}
}
// Usage
const blocks = [
createBlock('Introduction', 'h2'),
createBlock('This is a paragraph.'),
createBlock('First point', 'normal', {listItem: 'bullet', level: 1}),
createBlock('Second point', 'normal', {listItem: 'bullet', level: 1}),
]Validation Checklist
Before writing PT blocks to Sanity, verify:
- [ ] Every block has
_typeand_key - [ ] Every span has
_type: "span",_key,text, andmarks - [ ] Every annotation key in
marks[]has a matching entry inmarkDefs[] - [ ]
markDefsentries have_typeand_key - [ ]
_keyvalues are unique within the array - [ ]
stylevalues match your schema's allowed styles - [ ]
listItemvalues match your schema's allowed list types - [ ] Custom block
_typevalues match registered schema types
Reference
- Portable Text Specification
- @sanity/util — provides
randomKey()for generating_keyvalues
Convert Markdown to Portable Text
Use @portabletext/markdown for direct Markdown ↔ Portable Text conversion. This is the official library, part of the portabletext/editor monorepo.
npm install @portabletext/markdownBasic Usage
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText('# Hello **world**')Output:
[{
"_type": "block",
"_key": "f4s8k2",
"style": "h1",
"children": [
{"_type": "span", "_key": "a9c3x1", "text": "Hello ", "marks": []},
{"_type": "span", "_key": "b7d2m5", "text": "world", "marks": ["strong"]}
],
"markDefs": []
}]Supported Markdown Features
Out of the box:
- Headings (h1–h6)
- Paragraphs
- Bold, italic, inline code, strikethrough
- Links
- Blockquotes
- Ordered and unordered lists (including nested)
- Code blocks (fenced with language)
- Horizontal rules
- Images
- Tables (GFM)
- HTML blocks (configurable)
Custom Schema Mapping
Control how Markdown elements map to your PT schema. Define a schema with @portabletext/schema:
import {markdownToPortableText} from '@portabletext/markdown'
import {defineSchema, compileSchema} from '@portabletext/schema'
const schema = compileSchema(defineSchema({
styles: [{name: 'normal'}, {name: 'heading 1'}, {name: 'heading 2'}],
decorators: [{name: 'strong'}, {name: 'em'}],
annotations: [{name: 'link'}],
lists: [{name: 'bullet'}, {name: 'number'}],
}))
const blocks = markdownToPortableText(markdown, {
schema,
// Map Markdown heading levels to custom style names
block: {
h1: ({context}) => 'heading 1',
h2: ({context}) => 'heading 2',
},
})Using a Sanity Studio Schema
Use @portabletext/sanity-bridge to convert your Sanity block array schema:
import {markdownToPortableText} from '@portabletext/markdown'
import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge'
// Convert a Sanity block array schema to a Portable Text schema
const schema = sanitySchemaToPortableTextSchema(sanityBlockArraySchema)
const blocks = markdownToPortableText(markdown, {schema})Custom Matchers
Matchers are top-level options (not nested under a matchers key). Each receives {context, value} where context.schema lets you validate against the schema:
const blocks = markdownToPortableText(markdown, {
// Block matchers — map Markdown block elements to PT styles
block: {
h1: ({context}) => {
const style = context.schema.styles.find((s) => s.name === 'heading 1')
return style?.name // Return undefined to skip
},
},
// Mark matchers — map Markdown inline elements to PT marks
marks: {
strong: ({context}) => 'strong',
},
// Type matchers — map Markdown elements to custom PT block types
types: {
table: ({context, value}) => {
const tableType = context.schema.blockObjects.find((obj) => obj.name === 'table')
if (!tableType) return undefined
return {
_type: 'table',
_key: context.keyGenerator(),
rows: value.rows,
headerRows: value.headerRows,
}
},
},
})Handling Inline HTML
Configure how inline HTML in Markdown is processed:
const blocks = markdownToPortableText(markdown, {
html: {
inline: 'text', // 'text' preserves as text, 'skip' removes
},
})Custom Key Generation
Provide your own key generator:
import {randomKey} from '@sanity/util/content'
const blocks = markdownToPortableText(markdown, {
keyGenerator: () => randomKey(12),
})Bidirectional: Also Converts PT → Markdown
The same package provides portableTextToMarkdown():
import {portableTextToMarkdown} from '@portabletext/markdown'
const markdown = portableTextToMarkdown(blocks)See the portable-text-serialization skill's rules/markdown.md for details on PT → Markdown.
Migration Example
import {markdownToPortableText} from '@portabletext/markdown'
import {createClient} from '@sanity/client'
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const client = createClient({projectId: 'xxx', dataset: 'production', token: '...'})
// Import a directory of Markdown files
const mdFiles = fs.readdirSync('./content').filter(f => f.endsWith('.md'))
for (const file of mdFiles) {
const raw = fs.readFileSync(path.join('./content', file), 'utf-8')
const {data: frontmatter, content} = matter(raw)
const body = markdownToPortableText(content)
await client.createOrReplace({
_id: `post-${path.basename(file, '.md')}`,
_type: 'post',
title: frontmatter.title,
body,
})
}When to Use htmlToBlocks Instead
Use @portabletext/block-tools (htmlToBlocks) when:
- Your source is HTML, not Markdown
- You need custom deserializer rules for non-standard HTML elements
- You're migrating from a CMS that exports HTML (WordPress, Contentful, etc.)
- You need to handle complex HTML structures (tables with merged cells, nested divs, etc.)
For Markdown sources, @portabletext/markdown is simpler and more direct.
Reference
- @portabletext/markdown
- Part of the portabletext/editor monorepo
- Uses markdown-it internally
Related skills
How it compares
Use portable-text-conversion for HTML-rich legacy exports; switch to @portabletext/markdown when the source is already Markdown.
FAQ
Which conversion method should I use for Markdown content?
Use markdownToPortableText from @portabletext/markdown package - it is the recommended approach for Markdown sources and handles standard Markdown syntax conversion to PT blocks.
What are the key rules for Portable Text block construction?
Every block and span requires a unique _key within the array, _type identifies block category (block for text, or custom types), markDefs holds annotation data, and marks on spans reference markDefs._key or are decorator strings. Lists use listItem (bullet/number) and level prope
Can I manually construct Portable Text blocks from APIs or databases?
Yes, manual construction is the third approach - build PT blocks programmatically from any source by following the Portable Text specification structure and ensuring proper key generation and type assignment.
Is Portable Text Conversion safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.