@portabletext/markdown 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,429 @@
1
+ # `@portabletext/markdown`
2
+
3
+ > Convert Portable Text to Markdown and back again
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @portabletext/markdown
9
+ ```
10
+
11
+ ## Supported features
12
+
13
+ | Feature | Markdown → Portable Text | Portable Text → Markdown |
14
+ | ---------------- | ------------------------ | ------------------------ |
15
+ | Headings (h1–h6) | ✅ | ✅ |
16
+ | Paragraphs | ✅ | ✅ |
17
+ | Bold | ✅ | ✅ |
18
+ | Italic | ✅ | ✅ |
19
+ | Inline code | ✅ | ✅ |
20
+ | Strikethrough | ✅ | ✅ |
21
+ | Links | ✅ | ✅ |
22
+ | Blockquotes | ✅ | ✅ |
23
+ | Ordered lists | ✅ | ✅ |
24
+ | Unordered lists | ✅ | ✅ |
25
+ | Nested lists | ✅ | ✅ |
26
+ | Code blocks | ✅ | ✅\* |
27
+ | Horizontal rules | ✅ | ✅\* |
28
+ | Images | ✅ | ✅\* |
29
+ | Tables | ✅\* | ✅\* |
30
+ | HTML blocks | ✅ | ✅\* |
31
+
32
+ \* Requires custom configuration (see usage below)
33
+
34
+ ## Usage
35
+
36
+ ### `markdownToPortableText`
37
+
38
+ Converts a Markdown string to an array of Portable Text blocks.
39
+
40
+ ```ts
41
+ import {markdownToPortableText} from '@portabletext/markdown'
42
+
43
+ const markdown = `
44
+ # Hello World
45
+
46
+ This is a **bold** and *italic* text with a [link](https://example.com).
47
+
48
+ - First item
49
+ - Second item
50
+
51
+ > A blockquote
52
+ `
53
+
54
+ const blocks = markdownToPortableText(markdown)
55
+ ```
56
+
57
+ The conversion is driven by two concepts:
58
+
59
+ - **Schema**: Defines what Portable Text types are available (styles, lists, decorators, annotations, block objects). The library only outputs types that exist in the schema.
60
+ - **Matchers**: Control how Markdown elements map to schema types. For example, the `h1` matcher maps `# Heading` to the `'h1'` style.
61
+
62
+ Out of the box, the library includes sensible defaults for both. Customize them to match your content model.
63
+
64
+ ### Schema configuration
65
+
66
+ The default schema includes the following definitions:
67
+
68
+ | Type | Values |
69
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
70
+ | `styles` | `'normal'`, `'h1'`, `'h2'`, `'h3'`, `'h4'`, `'h5'`, `'h6'`, `'blockquote'` |
71
+ | `lists` | `'number'`, `'bullet'` |
72
+ | `decorators` | `'strong'`, `'em'`, `'code'`, `'strike-through'` |
73
+ | `annotations` | `'link'` (fields: `'href'`, `'title'`) |
74
+ | `blockObjects` | `'code'` (fields: `'language'`, `'code'`), `'image'` (fields: `'src'`, `'alt'`, `'title'`), `'horizontal-rule'`, `'html'` (fields: `'html'`), `'table'` (fields: `'headerRows'`, `'rows'`) |
75
+ | `inlineObjects` | `'image'` (fields: `'src'`, `'alt'`, `'title'`) |
76
+
77
+ To use a custom Schema, import `compileSchema` and `defineSchema` from `@portabletext/schema`:
78
+
79
+ ```ts
80
+ import {compileSchema, defineSchema} from '@portabletext/schema'
81
+
82
+ markdownToPortableText(markdown, {
83
+ schema: compileSchema(
84
+ defineSchema({
85
+ styles: [{name: 'normal'}, {name: 'heading 1'}],
86
+ }),
87
+ ),
88
+ })
89
+ ```
90
+
91
+ To use a Sanity schema, use `@portabletext/sanity-bridge` to convert it to a Portable Text Schema first:
92
+
93
+ ```ts
94
+ import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge'
95
+
96
+ // Convert a Sanity block array schema to a Portable Text schema
97
+ const schema = sanitySchemaToPortableTextSchema(sanityBlockArraySchema)
98
+
99
+ markdownToPortableText(markdown, {schema})
100
+ ```
101
+
102
+ ### Matchers
103
+
104
+ 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.
105
+
106
+ | Group | Matcher | Markdown | Maps to schema type |
107
+ | ---------- | ---------------- | ----------------------- | ------------------- |
108
+ | `block` | `normal` | Paragraphs | `'normal'` |
109
+ | | `h1`–`h6` | `#` – `######` headings | `'h1'`–`'h6'` |
110
+ | | `blockquote` | `>` blockquotes | `'blockquote'` |
111
+ | `listItem` | `bullet` | `- ` or `* ` lists | `'bullet'` |
112
+ | | `number` | `1. ` ordered lists | `'number'` |
113
+ | `marks` | `strong` | `**bold**` | `'strong'` |
114
+ | | `em` | `*italic*` | `'em'` |
115
+ | | `code` | `` `inline code` `` | `'code'` |
116
+ | | `strikeThrough` | `~~strikethrough~~` | `'strike-through'` |
117
+ | | `link` | `[text](url "title")` | `'link'` |
118
+ | `types` | `code` | Fenced code blocks | `'code'` |
119
+ | | `horizontalRule` | `---` | `'horizontal-rule'` |
120
+ | | `image` | `![alt](src)` | `'image'` |
121
+ | | `html` | HTML blocks | `'html'` |
122
+
123
+ #### Configuring matchers
124
+
125
+ You can provide custom matchers to change how Markdown maps to your schema.
126
+
127
+ **Custom heading style:** If your schema uses `'heading 1'` instead of `'h1'`:
128
+
129
+ ```ts
130
+ markdownToPortableText(markdown, {
131
+ schema: compileSchema(
132
+ defineSchema({
133
+ // Your schema including a 'heading 1' style
134
+ }),
135
+ ),
136
+ block: {
137
+ h1: ({context}) => {
138
+ // Check if 'heading 1' exists in the schema
139
+ const style = context.schema.styles.find((s) => s.name === 'heading 1')
140
+ return style?.name
141
+ },
142
+ },
143
+ })
144
+ ```
145
+
146
+ > **Note:** Checking if the type exists in the schema isn't required, but it's good practice. Returning `undefined` gracefully skips unsupported types.
147
+
148
+ **Table matcher:** Markdown tables are parsed but there's no default matcher. Provide one if your schema includes tables:
149
+
150
+ ```ts
151
+ markdownToPortableText(markdown, {
152
+ types: {
153
+ table: ({context, value}) => {
154
+ const tableType = context.schema.blockObjects.find(
155
+ (obj) => obj.name === 'table',
156
+ )
157
+ if (!tableType) return undefined
158
+
159
+ return {
160
+ _type: 'table',
161
+ _key: context.keyGenerator(),
162
+ rows: value.rows,
163
+ headerRows: value.headerRows,
164
+ }
165
+ },
166
+ },
167
+ })
168
+ ```
169
+
170
+ Matchers receive:
171
+
172
+ - `context.schema` – the compiled schema to validate against
173
+ - `context.keyGenerator` – function to generate unique keys
174
+ - `value` – the parsed Markdown data (structure depends on the matcher type)
175
+ - `isInline` – whether the element appears inline (for `ObjectMatcher` only)
176
+
177
+ Return `undefined` to skip the element (e.g., if the type isn't in the schema).
178
+
179
+ #### Default behavior for images and code
180
+
181
+ **Images** are handled based on context:
182
+
183
+ - Standalone images (a paragraph containing only an image) become block-level `'image'` objects
184
+ - Images mixed with text become inline `'image'` objects (if the schema includes `'image'` in `inlineObjects`)
185
+ - If neither is supported, falls back to plain text: `![alt](src)`
186
+
187
+ 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`.
188
+
189
+ **Code** is handled based on the Markdown syntax:
190
+
191
+ - Fenced code blocks (` ``` `) become `'code'` block objects with `language` and `code` fields
192
+ - Inline code (`` ` ``) applies the `'code'` decorator to a span
193
+
194
+ 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`.
195
+
196
+ **Links** support optional titles using `[text](url "title")` syntax. The title is captured in the `'title'` field of the `'link'` annotation.
197
+
198
+ **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.).
199
+
200
+ **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.
201
+
202
+ #### Other options
203
+
204
+ ```ts
205
+ markdownToPortableText(markdown, {
206
+ // Custom key generator for blocks and spans
207
+ keyGenerator: () => nanoid(),
208
+
209
+ // Configure how inline HTML is handled (default: 'skip')
210
+ html: {
211
+ inline: 'skip' | 'text', // 'skip' ignores inline HTML, 'text' converts it to plain text
212
+ },
213
+ })
214
+ ```
215
+
216
+ ### `portableTextToMarkdown`
217
+
218
+ Converts an array of Portable Text blocks to a Markdown string.
219
+
220
+ ```ts
221
+ import {portableTextToMarkdown} from '@portabletext/markdown'
222
+
223
+ const blocks = [
224
+ {
225
+ _type: 'block',
226
+ _key: 'abc123',
227
+ style: 'h1',
228
+ children: [{_type: 'span', _key: 'def456', text: 'Hello World', marks: []}],
229
+ markDefs: [],
230
+ },
231
+ ]
232
+
233
+ const markdown = portableTextToMarkdown(blocks)
234
+ // # Hello World
235
+ ```
236
+
237
+ 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.
238
+
239
+ #### Default renderers
240
+
241
+ | Group | Renderer | Renders | Output |
242
+ | ------------------- | ---------------- | ---------------------------- | --------------------- |
243
+ | `block` | `normal` | Paragraphs | `{children}` |
244
+ | | `h1`–`h6` | Headings | `# `–`###### ` |
245
+ | | `blockquote` | Blockquotes | `> {children}` |
246
+ | `marks` | `strong` | Bold text | `**{children}**` |
247
+ | | `em` | Italic text | `_{children}_` |
248
+ | | `code` | Inline code | `` `{children}` `` |
249
+ | | `underline` | Underlined text | `<u>{children}</u>` |
250
+ | | `strike-through` | Strikethrough | `~~{children}~~` |
251
+ | | `link` | Links | `[{children}](url)` |
252
+ | `listItem` | | List items (bullet & number) | `- ` or `1. ` |
253
+ | `hardBreak` | | Line breaks within blocks | ` \n` (two spaces) |
254
+ | `blockSpacing` | | Spacing between blocks | `\n\n`, `\n`, `\n>\n` |
255
+ | `unknownType` | | Unknown block types | JSON code block |
256
+ | `unknownBlockStyle` | | Unknown block styles | `{children}` |
257
+ | `unknownListItem` | | Unknown list item types | `- {children}` |
258
+ | `unknownMark` | | Unknown marks | `{children}` |
259
+
260
+ Unknown types render as JSON code blocks by default; unknown styles, list items, and marks pass through their children.
261
+
262
+ > **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.
263
+
264
+ #### Configuring renderers
265
+
266
+ Provide custom renderers to control how Portable Text renders to Markdown.
267
+
268
+ **Custom type renderers:** Render custom block types (objects in the blocks array):
269
+
270
+ ```ts
271
+ portableTextToMarkdown(blocks, {
272
+ types: {
273
+ callout: ({value}) => `> **${value.title}**\n> ${value.text}`,
274
+ },
275
+ })
276
+ ```
277
+
278
+ **Custom block styles:** Override how block styles render:
279
+
280
+ ```ts
281
+ portableTextToMarkdown(blocks, {
282
+ block: {
283
+ // Use ATX-style heading with closing hashes
284
+ h1: ({children}) => `# ${children} #`,
285
+ // Use HTML for blockquotes
286
+ blockquote: ({children}) => `<blockquote>${children}</blockquote>`,
287
+ },
288
+ })
289
+ ```
290
+
291
+ **Built-in type renderers:** The library exports default renderers for common block types:
292
+
293
+ ```ts
294
+ import {
295
+ DefaultCodeBlockRenderer,
296
+ DefaultHorizontalRuleRenderer,
297
+ DefaultHtmlRenderer,
298
+ DefaultImageRenderer,
299
+ DefaultTableRenderer,
300
+ portableTextToMarkdown,
301
+ } from '@portabletext/markdown'
302
+
303
+ portableTextToMarkdown(blocks, {
304
+ types: {
305
+ 'code': DefaultCodeBlockRenderer,
306
+ 'horizontal-rule': DefaultHorizontalRuleRenderer,
307
+ 'html': DefaultHtmlRenderer,
308
+ 'image': DefaultImageRenderer,
309
+ 'table': DefaultTableRenderer,
310
+ },
311
+ })
312
+ ```
313
+
314
+ | Renderer | Expected value | Output |
315
+ | ------------------------------- | --------------------------------------------- | ---------------------- |
316
+ | `DefaultCodeBlockRenderer` | `{code: string, language?: string}` | ` ```lang\ncode\n``` ` |
317
+ | `DefaultHorizontalRuleRenderer` | (no fields required) | `---` |
318
+ | `DefaultHtmlRenderer` | `{html: string}` | Raw HTML |
319
+ | `DefaultImageRenderer` | `{src: string, alt?: string, title?: string}` | `![alt](src "title")` |
320
+ | `DefaultTableRenderer` | `{rows: [...], headerRows?: number}` | Markdown table |
321
+
322
+ **Other exports:** The library also exports mark renderers, block style renderers, and TypeScript types:
323
+
324
+ - `DefaultStrongRenderer`, `DefaultEmRenderer`, `DefaultCodeRenderer`, `DefaultUnderlineRenderer`, `DefaultStrikeThroughRenderer`, `DefaultLinkRenderer`
325
+ - `DefaultNormalRenderer`, `DefaultBlockquoteRenderer`, `DefaultH1Renderer`–`DefaultH6Renderer`
326
+ - `DefaultListItemRenderer`, `DefaultHardBreakRenderer`, `DefaultBlockSpacingRenderer`
327
+ - `BlockSpacingRenderer`, `PortableTextRenderers`, `PortableTextMarkRenderer`, etc.
328
+
329
+ #### What renderers receive
330
+
331
+ **Block renderers** (`block.*`):
332
+
333
+ - `value` – the block object
334
+ - `children` – rendered content of the block
335
+ - `index` – position in the blocks array
336
+
337
+ **Mark renderers** (`marks.*`):
338
+
339
+ - `value` – the mark definition (for annotations like links)
340
+ - `children` – the rendered marked content
341
+ - `text` – the raw text content (without nested mark rendering)
342
+ - `markType` – the mark type name
343
+ - `markKey` – the mark's key (for annotations)
344
+
345
+ **Type renderers** (`types.*`):
346
+
347
+ - `value` – the typed object
348
+ - `index` – position in the blocks array
349
+ - `isInline` – whether it appears inline or as a block
350
+
351
+ Use `isInline` to handle block vs inline objects differently:
352
+
353
+ ```ts
354
+ portableTextToMarkdown(blocks, {
355
+ types: {
356
+ image: ({value, isInline}) => {
357
+ if (isInline) {
358
+ // Skip inline images entirely by returning empty string
359
+ return ''
360
+ }
361
+ // Render block images as full Markdown
362
+ return `![${value.alt || ''}](${value.src})`
363
+ },
364
+ },
365
+ })
366
+ ```
367
+
368
+ Return an empty string to skip rendering an element entirely.
369
+
370
+ **List item renderer** (`listItem`):
371
+
372
+ - `value` – the list item block
373
+ - `children` – rendered content
374
+ - `listIndex` – position in the list (for numbered lists)
375
+
376
+ #### Handling unknown types
377
+
378
+ The library provides fallback renderers for unknown content:
379
+
380
+ ```ts
381
+ portableTextToMarkdown(blocks, {
382
+ // Called for block types not in `types`
383
+ unknownType: ({value}) => `<!-- Unknown type: ${value._type} -->`,
384
+
385
+ // Called for block styles not in `block`
386
+ unknownBlockStyle: ({value, children}) => children ?? '',
387
+
388
+ // Called for list item types not in `listItem`
389
+ unknownListItem: ({children}) => `- ${children}`,
390
+
391
+ // Called for marks not in `marks`
392
+ unknownMark: ({children}) => children,
393
+ })
394
+ ```
395
+
396
+ By default, unknown types render as JSON code blocks, and unknown marks/styles pass through their children unchanged.
397
+
398
+ You can also customize hard break rendering:
399
+
400
+ ```ts
401
+ portableTextToMarkdown(blocks, {
402
+ // Render as HTML break instead of Markdown hard break
403
+ hardBreak: () => '<br />\n',
404
+
405
+ // Or render as plain newline (no trailing spaces)
406
+ hardBreak: () => '\n',
407
+ })
408
+ ```
409
+
410
+ #### Block spacing
411
+
412
+ By default, blocks are separated by double newlines (`\n\n`), with special handling for list items (single newline) and consecutive blockquotes. Customize with `blockSpacing`:
413
+
414
+ ```ts
415
+ portableTextToMarkdown(blocks, {
416
+ blockSpacing: ({current, next}) => {
417
+ // Double newline between list items instead of single
418
+ if (current.listItem && next.listItem) {
419
+ return '\n\n'
420
+ }
421
+ // Return undefined to use default spacing
422
+ return undefined
423
+ },
424
+ })
425
+ ```
426
+
427
+ ## License
428
+
429
+ MIT © [Sanity.io](https://www.sanity.io/)