@portabletext/markdown
v1.1.2
Published
Convert Portable Text to and from Markdown
Readme
@portabletext/markdown
Convert Portable Text to Markdown and back again
Installation
npm install @portabletext/markdownQuick start
Markdown → Portable Text
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText('# Hello **world**')[
{
"_type": "block",
"_key": "f4s8k2",
"style": "h1",
"children": [
{"_type": "span", "_key": "a9c3x1", "text": "Hello ", "marks": []},
{"_type": "span", "_key": "b7d2m5", "text": "world", "marks": ["strong"]}
],
"markDefs": []
}
]Portable Text → Markdown
import {portableTextToMarkdown} from '@portabletext/markdown'
const markdown = portableTextToMarkdown([
{
_type: 'block',
_key: 'f4s8k2',
style: 'h1',
children: [
{_type: 'span', _key: 'a9c3x1', text: 'Hello ', marks: []},
{_type: 'span', _key: 'b7d2m5', text: 'world', marks: ['strong']},
],
markDefs: [],
},
])# Hello **world**Supported features
| Feature | Markdown → Portable Text | Portable Text → Markdown | | ---------------- | ------------------------ | ------------------------ | | Headings (h1–h6) | ✅ | ✅ | | Paragraphs | ✅ | ✅ | | Bold | ✅ | ✅ | | Italic | ✅ | ✅ | | Inline code | ✅ | ✅ | | Strikethrough | ✅ | ✅ | | Links | ✅ | ✅ | | Blockquotes | ✅ | ✅ | | Ordered lists | ✅ | ✅ | | Unordered lists | ✅ | ✅ | | Nested lists | ✅ | ✅ | | Code blocks | ✅ | ✅* | | Horizontal rules | ✅ | ✅* | | Images | ✅ | ✅* | | Tables | ✅* | ✅* | | HTML blocks | ✅ | ✅* |
* Requires custom configuration (see usage below)
Usage
markdownToPortableText
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText(`
# Hello World
This is **bold** and *italic* text with a [link](https://example.com).
- First item
- Second item
`)[
{
"_type": "block",
"_key": "k9f2x1",
"style": "h1",
"children": [
{"_type": "span", "_key": "s1a2b3", "text": "Hello World", "marks": []}
],
"markDefs": []
},
{
"_type": "block",
"_key": "m3n4p5",
"style": "normal",
"children": [
{"_type": "span", "_key": "s2c3d4", "text": "This is ", "marks": []},
{"_type": "span", "_key": "s3e4f5", "text": "bold", "marks": ["strong"]},
{"_type": "span", "_key": "s4g5h6", "text": " and ", "marks": []},
{"_type": "span", "_key": "s5i6j7", "text": "italic", "marks": ["em"]},
{"_type": "span", "_key": "s6k7l8", "text": " text with a ", "marks": []},
{"_type": "span", "_key": "s7m8n9", "text": "link", "marks": ["a1b2c3"]},
{"_type": "span", "_key": "s8o9p0", "text": ".", "marks": []}
],
"markDefs": [
{"_type": "link", "_key": "a1b2c3", "href": "https://example.com"}
]
},
{
"_type": "block",
"_key": "q1r2s3",
"style": "normal",
"listItem": "bullet",
"level": 1,
"children": [
{"_type": "span", "_key": "s9q0r1", "text": "First item", "marks": []}
],
"markDefs": []
},
{
"_type": "block",
"_key": "t4u5v6",
"style": "normal",
"listItem": "bullet",
"level": 1,
"children": [
{"_type": "span", "_key": "s0s1t2", "text": "Second item", "marks": []}
],
"markDefs": []
}
]The conversion is driven by two concepts:
- Schema: Defines what Portable Text types are available (styles, lists, decorators, annotations, block objects). The library only outputs types that exist in the schema.
- Matchers: Control how Markdown elements map to schema types. For example, the
h1matcher maps# Headingto the'h1'style.
Out of the box, the library includes sensible defaults for both. Customize them to match your content model.
Schema configuration
The default schema includes the following definitions:
| Type | Values |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| styles | 'normal', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote' |
| lists | 'number', 'bullet' |
| decorators | 'strong', 'em', 'code', 'strike-through' |
| annotations | 'link' (fields: 'href', 'title') |
| blockObjects | 'code' (fields: 'language', 'code'), 'image' (fields: 'src', 'alt', 'title'), 'horizontal-rule', 'html' (fields: 'html'), 'table' (fields: 'headerRows', 'rows') |
| inlineObjects | 'image' (fields: 'src', 'alt', 'title') |
To use a custom Schema, import compileSchema and defineSchema from @portabletext/schema:
import {compileSchema, defineSchema} from '@portabletext/schema'
markdownToPortableText(markdown, {
schema: compileSchema(
defineSchema({
styles: [{name: 'normal'}, {name: 'heading 1'}],
}),
),
})To use a Sanity schema, use @portabletext/sanity-bridge to convert it to a Portable Text Schema first:
import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge'
// Convert a Sanity block array schema to a Portable Text schema
const schema = sanitySchemaToPortableTextSchema(sanityBlockArraySchema)
markdownToPortableText(markdown, {schema})Matchers
Matchers map Markdown concepts to Portable Text types defined in the Schema. Each default matcher checks if a type exists in the schema and returns the appropriate value.
| Group | Matcher | Markdown | Maps to schema type |
| ---------- | ---------------- | ----------------------- | ------------------- |
| block | normal | Paragraphs | 'normal' |
| | h1–h6 | # – ###### headings | 'h1'–'h6' |
| | blockquote | > blockquotes | 'blockquote' |
| listItem | bullet | - or * lists | 'bullet' |
| | number | 1. ordered lists | 'number' |
| marks | strong | **bold** | 'strong' |
| | em | *italic* | 'em' |
| | code | `inline code` | 'code' |
| | strikeThrough | ~~strikethrough~~ | 'strike-through' |
| | link | [text](url "title") | 'link' |
| types | code | Fenced code blocks | 'code' |
| | horizontalRule | --- | 'horizontal-rule' |
| | image |  | 'image' |
| | html | HTML blocks | 'html' |
Configuring matchers
You can provide custom matchers to change how Markdown maps to your schema.
Custom heading style: If your schema uses 'heading 1' instead of 'h1':
markdownToPortableText(markdown, {
schema: compileSchema(
defineSchema({
// Your schema including a 'heading 1' style
}),
),
block: {
h1: ({context}) => {
// Check if 'heading 1' exists in the schema
const style = context.schema.styles.find((s) => s.name === 'heading 1')
return style?.name
},
},
})Note: Checking if the type exists in the schema isn't required, but it's good practice. Returning
undefinedgracefully skips unsupported types.
Table matcher: Markdown tables are parsed but there's no default matcher. Provide one if your schema includes tables:
markdownToPortableText(markdown, {
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,
}
},
},
})Matchers receive:
context.schema– the compiled schema to validate againstcontext.keyGenerator– function to generate unique keysvalue– the parsed Markdown data (structure depends on the matcher type)isInline– whether the element appears inline (forObjectMatcheronly)
Return undefined to skip the element (e.g., if the type isn't in the schema).
Default behavior for images and code
Images are handled based on context:
- Standalone images (a paragraph containing only an image) become block-level
'image'objects - Images mixed with text become inline
'image'objects (if the schema includes'image'ininlineObjects) - If neither is supported, falls back to plain text:

The default image matcher requires the schema type to have a 'src' field. If your 'image' type doesn't include this field, the matcher returns undefined.
Code is handled based on the Markdown syntax:
- Fenced code blocks (
```) become'code'block objects withlanguageandcodefields - Inline code (
`) applies the'code'decorator to a span
The default code block matcher requires the schema type to have a 'code' field. If your 'code' type doesn't include this field, the matcher returns undefined.
Links support optional titles using [text](url "title") syntax. The title is captured in the 'title' field of the 'link' annotation.
Nested lists are handled automatically. Each list item block includes a level property indicating its nesting depth (1 for top-level, 2 for nested, etc.).
HTML blocks (like <div>...</div>) become 'html' block objects with the raw HTML in the 'html' field. Inline HTML is controlled by the html.inline option.
Other options
markdownToPortableText(markdown, {
// Custom key generator for blocks and spans
keyGenerator: () => nanoid(),
// Configure how inline HTML is handled (default: 'skip')
html: {
inline: 'skip' | 'text', // 'skip' ignores inline HTML, 'text' converts it to plain text
},
})portableTextToMarkdown
import {portableTextToMarkdown} from '@portabletext/markdown'
const markdown = portableTextToMarkdown([
{
_type: 'block',
_key: 'k9f2x1',
style: 'h1',
children: [{_type: 'span', _key: 's1a2b3', text: 'Hello World', marks: []}],
markDefs: [],
},
{
_type: 'block',
_key: 'm3n4p5',
style: 'normal',
children: [
{_type: 'span', _key: 's2c3d4', text: 'This is ', marks: []},
{_type: 'span', _key: 's3e4f5', text: 'bold', marks: ['strong']},
{_type: 'span', _key: 's4g5h6', text: ' and ', marks: []},
{_type: 'span', _key: 's5i6j7', text: 'italic', marks: ['em']},
{_type: 'span', _key: 's6k7l8', text: ' text with a ', marks: []},
{_type: 'span', _key: 's7m8n9', text: 'link', marks: ['a1b2c3']},
{_type: 'span', _key: 's8o9p0', text: '.', marks: []},
],
markDefs: [{_type: 'link', _key: 'a1b2c3', href: 'https://example.com'}],
},
{
_type: 'block',
_key: 'q1r2s3',
style: 'normal',
listItem: 'bullet',
level: 1,
children: [{_type: 'span', _key: 's9q0r1', text: 'First item', marks: []}],
markDefs: [],
},
{
_type: 'block',
_key: 't4u5v6',
style: 'normal',
listItem: 'bullet',
level: 1,
children: [{_type: 'span', _key: 's0s1t2', text: 'Second item', marks: []}],
markDefs: [],
},
])# Hello World
This is **bold** and _italic_ text with a [link](https://example.com).
- First item
- Second itemThe conversion is driven by Renderers: functions that render Portable Text elements to Markdown strings. The library includes default renderers for common types; provide your own for custom block types.
Default renderers
| Group | Renderer | Renders | Output |
| ------------------- | ---------------- | ---------------------------- | --------------------- |
| block | normal | Paragraphs | {children} |
| | h1–h6 | Headings | # –###### |
| | blockquote | Blockquotes | > {children} |
| marks | strong | Bold text | **{children}** |
| | em | Italic text | _{children}_ |
| | code | Inline code | `{children}` |
| | underline | Underlined text | <u>{children}</u> |
| | strike-through | Strikethrough | ~~{children}~~ |
| | link | Links | [{children}](url) |
| listItem | | List items (bullet & number) | - or 1. |
| hardBreak | | Line breaks within blocks | \n (two spaces) |
| blockSpacing | | Spacing between blocks | \n\n, \n, \n>\n |
| unknownType | | Unknown block types | JSON code block |
| unknownBlockStyle | | Unknown block styles | {children} |
| unknownListItem | | Unknown list item types | - {children} |
| unknownMark | | Unknown marks | {children} |
Unknown types render as JSON code blocks by default; unknown styles, list items, and marks pass through their children.
Note: The
underlinerenderer is included for Portable Text that uses it, but there's no standard Markdown syntax for underline, so it renders as HTML.
Configuring renderers
Provide custom renderers to control how Portable Text renders to Markdown.
Custom type renderers: Render custom block types (objects in the blocks array):
portableTextToMarkdown(blocks, {
types: {
callout: ({value}) => `> **${value.title}**\n> ${value.text}`,
},
})Custom block styles: Override how block styles render:
portableTextToMarkdown(blocks, {
block: {
// Use ATX-style heading with closing hashes
h1: ({children}) => `# ${children} #`,
// Use HTML for blockquotes
blockquote: ({children}) => `<blockquote>${children}</blockquote>`,
},
})Built-in type renderers: The library exports default renderers for common block types:
import {
DefaultCodeBlockRenderer,
DefaultHorizontalRuleRenderer,
DefaultHtmlRenderer,
DefaultImageRenderer,
DefaultTableRenderer,
portableTextToMarkdown,
} from '@portabletext/markdown'
portableTextToMarkdown(blocks, {
types: {
'code': DefaultCodeBlockRenderer,
'horizontal-rule': DefaultHorizontalRuleRenderer,
'html': DefaultHtmlRenderer,
'image': DefaultImageRenderer,
'table': DefaultTableRenderer,
},
})| Renderer | Expected value | Output |
| ------------------------------- | --------------------------------------------- | ---------------------- |
| DefaultCodeBlockRenderer | {code: string, language?: string} | ```lang\ncode\n``` |
| DefaultHorizontalRuleRenderer | (no fields required) | --- |
| DefaultHtmlRenderer | {html: string} | Raw HTML |
| DefaultImageRenderer | {src: string, alt?: string, title?: string} |  |
| DefaultTableRenderer | {rows: [...], headerRows?: number} | Markdown table |
What renderers receive
Block renderers (block.*):
value– the block objectchildren– rendered content of the blockindex– position in the blocks array
Mark renderers (marks.*):
value– the mark definition (for annotations like links)children– the rendered marked contenttext– the raw text content (without nested mark rendering)markType– the mark type namemarkKey– the mark's key (for annotations)
Type renderers (types.*):
value– the typed objectindex– position in the blocks arrayisInline– whether it appears inline or as a block
Use isInline to handle block vs inline objects differently:
portableTextToMarkdown(blocks, {
types: {
image: ({value, isInline}) => {
if (isInline) {
// Skip inline images entirely by returning empty string
return ''
}
// Render block images as full Markdown
return ``
},
},
})Return an empty string to skip rendering an element entirely.
List item renderer (listItem):
value– the list item blockchildren– rendered contentlistIndex– position in the list (for numbered lists)
Handling unknown types
The library provides fallback renderers for unknown content:
portableTextToMarkdown(blocks, {
// Called for block types not in `types`
unknownType: ({value}) => `<!-- Unknown type: ${value._type} -->`,
// Called for block styles not in `block`
unknownBlockStyle: ({value, children}) => children ?? '',
// Called for list item types not in `listItem`
unknownListItem: ({children}) => `- ${children}`,
// Called for marks not in `marks`
unknownMark: ({children}) => children,
})By default, unknown types render as JSON code blocks, and unknown marks/styles pass through their children unchanged.
You can also customize hard break rendering:
portableTextToMarkdown(blocks, {
// Render as HTML break instead of Markdown hard break
hardBreak: () => '<br />\n',
// Or render as plain newline (no trailing spaces)
hardBreak: () => '\n',
})Block spacing
By default, blocks are separated by double newlines (\n\n), with special handling for list items (single newline) and consecutive blockquotes. Customize with blockSpacing:
portableTextToMarkdown(blocks, {
blockSpacing: ({current, next}) => {
// Double newline between list items instead of single
if (current.listItem && next.listItem) {
return '\n\n'
}
// Return undefined to use default spacing
return undefined
},
})License
MIT © Sanity.io
