npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/markdown

Quick 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 h1 matcher maps # Heading to 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' | | | h1h6 | ####### 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 | ![alt](src) | '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 undefined gracefully 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 against
  • context.keyGenerator – function to generate unique keys
  • value – the parsed Markdown data (structure depends on the matcher type)
  • isInline – whether the element appears inline (for ObjectMatcher only)

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' in inlineObjects)
  • If neither is supported, falls back to plain text: ![alt](src)

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 with language and code fields
  • 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 item

The 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} | | | h1h6 | 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 underline renderer 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} | ![alt](src "title") | | DefaultTableRenderer | {rows: [...], headerRows?: number} | Markdown table |

What renderers receive

Block renderers (block.*):

  • value – the block object
  • children – rendered content of the block
  • index – position in the blocks array

Mark renderers (marks.*):

  • value – the mark definition (for annotations like links)
  • children – the rendered marked content
  • text – the raw text content (without nested mark rendering)
  • markType – the mark type name
  • markKey – the mark's key (for annotations)

Type renderers (types.*):

  • value – the typed object
  • index – position in the blocks array
  • isInline – 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 `![${value.alt || ''}](${value.src})`
    },
  },
})

Return an empty string to skip rendering an element entirely.

List item renderer (listItem):

  • value – the list item block
  • children – rendered content
  • listIndex – 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