# `@portabletext/markdown`

> Convert Portable Text to Markdown and back again

## Installation

```bash
npm install @portabletext/markdown
```

## Quick start

**Markdown → Portable Text**

```ts
import {markdownToPortableText} from '@portabletext/markdown'

const blocks = markdownToPortableText('# Hello **world**')
```

```json
[
  {
    "_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**

```ts
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: [],
  },
])
```

```md
# 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  | ✅                       | ✅                       |
| Task lists       | ✅\*                     | ✅\*                     |
| Nested lists     | ✅                       | ✅                       |
| Code blocks      | ✅                       | ✅\*                     |
| Horizontal rules | ✅                       | ✅\*                     |
| Images           | ✅                       | ✅\*                     |
| Tables           | ✅\*                     | ✅\*                     |
| HTML blocks      | ✅                       | ✅\*                     |
| Callouts         | ✅\*                     | ✅\*                     |

\* Requires custom configuration (see usage below)

## Usage

### `markdownToPortableText`

```ts
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
`)
```

```json
[
  {
    "_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'`), `'callout'` (fields: `'tone'`, `'content'`) |
| `inlineObjects` | `'image'` (fields: `'src'`, `'alt'`, `'title'`)                                                                                                                                                                                         |

To use a custom Schema, import `compileSchema` and `defineSchema` from `@portabletext/schema`:

```ts
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:

```ts
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'`          |
|            | `task`           | `- [ ]` / `- [x]` items | `'task'`            |
| `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'`            |
|            | `callout`        | `> [!NOTE]`, etc.       | `'callout'`         |
|            | `blockquote`     | `>` blockquotes         | `'blockquote'`      |
|            | `list`           | `- ` or `1. ` lists     | `'list'`            |

#### 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'`:

```ts
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:

```ts
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,
      }
    },
  },
})
```

**List matcher:** By default, lists are emitted as flat text blocks with `listItem` and `level` fields, and adjacent blocks form a list at render time. If your schema models lists as a structural block-object instead (a `list` type with an `items` array holding `list-item` objects, each with a `content` array), provide a `types.list` matcher to opt into that shape:

```ts
markdownToPortableText(markdown, {
  schema: schemaWithList,
  types: {
    list: ({context, value}) => ({
      _type: 'list',
      _key: context.keyGenerator(),
      kind: value.kind, // 'bullet' | 'number' | 'task'
      items: value.items, // each item: {_type, _key, checked?, content: [...]}
    }),
  },
})
```

The matcher receives `value.items` already assembled. Each item's `content` array holds whatever blocks the markdown produced inside the item: text blocks, code blocks, callouts, images, even nested lists. `kind` is promoted to `'task'` automatically when any item carries a GFM checkbox (`- [ ]` / `- [x]`); items only carry a `checked` field when the markdown actually has one. If the matcher returns `undefined`, the parser falls back to flat-list parsing for that list.

Without `types.list`, the existing flat-block path runs unchanged.

**GFM task lists** (`- [ ]` / `- [x]`): Task lists are recognized when the schema declares a `task` list item. Without a `task` definition, the checkbox markers are stripped from the text and the items render as their surrounding list type (bullet or number). With a `task` definition, items carrying a checkbox become text blocks with `listItem: 'task'` and a `checked: boolean` field; items without a checkbox keep their surrounding list type.

```ts
markdownToPortableText('- [x] done\n- [ ] todo', {
  schema: compileSchema(
    defineSchema({
      lists: [{name: 'bullet'}, {name: 'task'}],
    }),
  ),
})
// → [
//   {_type: 'block', listItem: 'task', level: 1, checked: true,  children: [{text: 'done', ...}], ...},
//   {_type: 'block', listItem: 'task', level: 1, checked: false, children: [{text: 'todo', ...}], ...},
// ]
```

If your schema uses a different name for the task list type (e.g. `'todo'`), provide a custom `listItem.task` matcher:

```ts
markdownToPortableText(markdown, {
  schema: compileSchema(defineSchema({lists: [{name: 'todo'}]})),
  listItem: {
    task: ({context}) =>
      context.schema.lists.find((list) => list.name === 'todo')?.name,
  },
})
```

When emitting Portable Text back to Markdown, blocks with `listItem: 'task'` render as `- [x] ` or `- [ ] ` based on the `checked` field.

**Blockquote matcher:** By default, blockquotes are emitted as flat text blocks with `style: 'blockquote'`, and adjacent blocks form a visual blockquote at render time. If your schema models a blockquote as a structural block-object instead (a `blockquote` type with a `content` array), provide a `types.blockquote` matcher to opt into that shape:

```ts
markdownToPortableText(markdown, {
  schema: schemaWithBlockquote,
  types: {
    blockquote: ({context, value}) => ({
      _type: 'blockquote',
      _key: context.keyGenerator(),
      content: value.content, // array of blocks the markdown produced inside the blockquote
    }),
  },
})
```

The matcher receives `value.content` already assembled. The array holds whatever blocks the markdown produced inside the blockquote: text blocks, code blocks, images, even nested blockquotes. GFM alerts (`> [!NOTE]`, `> [!TIP]`, etc.) use a different token stream and produce callouts instead, so `types.blockquote` and `types.callout` can be registered side-by-side without conflict.

If the matcher returns `undefined`, the parser falls back to flat-style by re-emitting the content blocks with `style: 'blockquote'`.

Without `types.blockquote`, the existing flat-block path runs unchanged.

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.

**Callouts** use the `> [!TYPE]` syntax (GFM alerts) where `TYPE` is one of `NOTE`, `TIP`, `WARNING`, `CAUTION`, or `IMPORTANT`. They become `'callout'` block objects with a `'tone'` field (the lowercased type name) and a `'content'` field (an array of Portable Text blocks). When the schema doesn't include a `'callout'` block object, the content falls back to blockquote-styled blocks.

#### Other options

```ts
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`

```ts
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: [],
  },
])
```

```md
# 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}`             |
|                     | `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, task) | `- `, `1. `, or `- [x] ` |
| `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):

```ts
portableTextToMarkdown(blocks, {
  types: {
    // Render a custom "chart" block object
    chart: ({value}) => `![${value.title}](${value.imageUrl})`,
  },
})
```

**Custom block styles:** Override how block styles render:

```ts
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:

```ts
import {
  DefaultBlockquoteObjectRenderer,
  DefaultCalloutRenderer,
  DefaultCodeBlockRenderer,
  DefaultHorizontalRuleRenderer,
  DefaultHtmlRenderer,
  DefaultImageRenderer,
  DefaultListRenderer,
  DefaultTableRenderer,
  portableTextToMarkdown,
} from '@portabletext/markdown'

portableTextToMarkdown(blocks, {
  types: {
    'blockquote': DefaultBlockquoteObjectRenderer,
    'callout': DefaultCalloutRenderer,
    'code': DefaultCodeBlockRenderer,
    'horizontal-rule': DefaultHorizontalRuleRenderer,
    'html': DefaultHtmlRenderer,
    'image': DefaultImageRenderer,
    'list': DefaultListRenderer,
    'table': DefaultTableRenderer,
  },
})
```

| Renderer                          | Expected value                                         | Output                 |
| --------------------------------- | ------------------------------------------------------ | ---------------------- |
| `DefaultBlockquoteObjectRenderer` | `{content: PortableTextBlock[]}`                       | `> content`            |
| `DefaultCalloutRenderer`          | `{tone: string, content: PortableTextBlock[]}`         | `> [!TYPE]\n> content` |
| `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")`  |
| `DefaultListRenderer`             | `{kind: 'bullet' \| 'number' \| 'task', items: [...]}` | Markdown list          |
| `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:

```ts
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:

```ts
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:

```ts
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`:

```ts
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](https://www.sanity.io/)
