add skills
Build and Deploy / build-and-deploy (push) Successful in 2m53s

This commit is contained in:
2026-05-18 08:39:42 +02:00
parent 92a80e8759
commit 57af0b8386
53 changed files with 10190 additions and 0 deletions
@@ -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 (h1h6)
- 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