This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
---
|
||||
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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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](https://github.com/portabletext/editor/tree/main/packages/block-tools) — part of the `portabletext/editor` monorepo
|
||||
- [Sanity Migration docs](https://www.sanity.io/docs/schema-and-content-migrations)
|
||||
- [portabletext.org](https://www.portabletext.org) — Editor docs and serializer list
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: Manually Construct Portable Text Blocks
|
||||
description: Build Portable Text blocks programmatically from any data source
|
||||
tags: [portable-text, construction, api, programmatic, migration]
|
||||
---
|
||||
|
||||
# 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`:
|
||||
|
||||
```ts
|
||||
import {randomKey} from '@sanity/util/content'
|
||||
|
||||
const key = randomKey(12) // e.g., "a1b2c3d4e5f6"
|
||||
```
|
||||
|
||||
Or use a simple helper:
|
||||
|
||||
```ts
|
||||
const randomKey = () => Math.random().toString(36).slice(2, 14)
|
||||
```
|
||||
|
||||
## Building Blocks
|
||||
|
||||
### Simple Paragraph
|
||||
|
||||
```ts
|
||||
{
|
||||
_type: 'block',
|
||||
_key: randomKey(),
|
||||
style: 'normal',
|
||||
children: [
|
||||
{_type: 'span', _key: randomKey(), text: 'Hello world', marks: []}
|
||||
],
|
||||
markDefs: []
|
||||
}
|
||||
```
|
||||
|
||||
### Heading
|
||||
|
||||
```ts
|
||||
{
|
||||
_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)
|
||||
|
||||
```ts
|
||||
{
|
||||
_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`:
|
||||
|
||||
```ts
|
||||
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):
|
||||
|
||||
```ts
|
||||
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`:
|
||||
|
||||
```ts
|
||||
// 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:
|
||||
|
||||
```ts
|
||||
// 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:
|
||||
|
||||
```ts
|
||||
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 `_type` and `_key`
|
||||
- [ ] Every span has `_type: "span"`, `_key`, `text`, and `marks`
|
||||
- [ ] Every annotation key in `marks[]` has a matching entry in `markDefs[]`
|
||||
- [ ] `markDefs` entries have `_type` and `_key`
|
||||
- [ ] `_key` values are unique within the array
|
||||
- [ ] `style` values match your schema's allowed styles
|
||||
- [ ] `listItem` values match your schema's allowed list types
|
||||
- [ ] Custom block `_type` values match registered schema types
|
||||
|
||||
## Reference
|
||||
|
||||
- [Portable Text Specification](https://github.com/portabletext/portabletext)
|
||||
- [@sanity/util](https://github.com/sanity-io/sanity/tree/next/packages/%40sanity/util) — provides `randomKey()` for generating `_key` values
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
title: Convert Markdown to Portable Text
|
||||
description: Convert Markdown content into Portable Text blocks using @portabletext/markdown
|
||||
tags: [portable-text, markdown, conversion, migration, import]
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
```bash
|
||||
npm install @portabletext/markdown
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```ts
|
||||
import {markdownToPortableText} from '@portabletext/markdown'
|
||||
|
||||
const blocks = markdownToPortableText('# Hello **world**')
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
[{
|
||||
"_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`:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
const blocks = markdownToPortableText(markdown, {
|
||||
html: {
|
||||
inline: 'text', // 'text' preserves as text, 'skip' removes
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Key Generation
|
||||
|
||||
Provide your own key generator:
|
||||
|
||||
```ts
|
||||
import {randomKey} from '@sanity/util/content'
|
||||
|
||||
const blocks = markdownToPortableText(markdown, {
|
||||
keyGenerator: () => randomKey(12),
|
||||
})
|
||||
```
|
||||
|
||||
## Bidirectional: Also Converts PT → Markdown
|
||||
|
||||
The same package provides `portableTextToMarkdown()`:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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](https://github.com/portabletext/editor/tree/main/packages/markdown)
|
||||
- Part of the [portabletext/editor](https://github.com/portabletext/editor) monorepo
|
||||
- Uses [markdown-it](https://github.com/markdown-it/markdown-it) internally
|
||||
Reference in New Issue
Block a user