@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.
@@ -0,0 +1,133 @@
1
+ import {
2
+ compileSchema,
3
+ defineSchema,
4
+ isTextBlock,
5
+ type PortableTextBlock,
6
+ } from '@portabletext/schema'
7
+ import type {ArbitraryTypedObject, TypedObject} from '@portabletext/types'
8
+ import {defaultKeyGenerator} from '../key-generator'
9
+
10
+ const schema = compileSchema(defineSchema({}))
11
+
12
+ /**
13
+ * Builds a map of list item `_key`s to their index.
14
+ *
15
+ * Mutates the blocks in place by adding a `_key` if necessary.
16
+ */
17
+ export function buildListIndexMap<
18
+ Block extends TypedObject = PortableTextBlock | ArbitraryTypedObject,
19
+ >(blocks: Array<Block>): Map<string, number> {
20
+ const levelIndexMaps = new Map<string, Map<number, number>>()
21
+ const listIndexMap = new Map<string, number>()
22
+
23
+ let previousListItem:
24
+ | {
25
+ listItem: string
26
+ level: number
27
+ }
28
+ | undefined
29
+
30
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
31
+ const block = blocks.at(blockIndex)
32
+
33
+ if (block === undefined) {
34
+ continue
35
+ }
36
+
37
+ if (!block._key) {
38
+ block._key = defaultKeyGenerator()
39
+ }
40
+
41
+ // Clear the state if we encounter a non-text block
42
+ if (!isTextBlock({schema}, block)) {
43
+ levelIndexMaps.clear()
44
+ previousListItem = undefined
45
+
46
+ continue
47
+ }
48
+
49
+ // Clear the state if we encounter a non-list text block
50
+ if (block.listItem === undefined || block.level === undefined) {
51
+ levelIndexMaps.clear()
52
+ previousListItem = undefined
53
+
54
+ continue
55
+ }
56
+
57
+ // If we encounter a new list item, we set the initial index to 1 for the
58
+ // list type on that level.
59
+ if (!previousListItem) {
60
+ const listIndex = 1
61
+ const levelIndexMap =
62
+ levelIndexMaps.get(block.listItem) ?? new Map<number, number>()
63
+ levelIndexMap.set(block.level, listIndex)
64
+ levelIndexMaps.set(block.listItem, levelIndexMap)
65
+
66
+ listIndexMap.set(block._key, listIndex)
67
+
68
+ previousListItem = {
69
+ listItem: block.listItem,
70
+ level: block.level,
71
+ }
72
+
73
+ continue
74
+ }
75
+
76
+ // If the previous list item is of the same type but on a lower level, we
77
+ // need to reset the level index map for that type.
78
+ if (
79
+ previousListItem.listItem === block.listItem &&
80
+ previousListItem.level < block.level
81
+ ) {
82
+ const listIndex = 1
83
+ const levelIndexMap =
84
+ levelIndexMaps.get(block.listItem) ?? new Map<number, number>()
85
+ levelIndexMap.set(block.level, listIndex)
86
+ levelIndexMaps.set(block.listItem, levelIndexMap)
87
+
88
+ listIndexMap.set(block._key, listIndex)
89
+
90
+ previousListItem = {
91
+ listItem: block.listItem,
92
+ level: block.level,
93
+ }
94
+
95
+ continue
96
+ }
97
+
98
+ // Reset other list types at current level and deeper
99
+ levelIndexMaps.forEach((levelIndexMap, listItem) => {
100
+ if (listItem === block.listItem) {
101
+ return
102
+ }
103
+
104
+ // Reset all levels that are >= current level
105
+ const levelsToDelete: number[] = []
106
+
107
+ levelIndexMap.forEach((_, level) => {
108
+ if (level >= block.level!) {
109
+ levelsToDelete.push(level)
110
+ }
111
+ })
112
+
113
+ levelsToDelete.forEach((level) => {
114
+ levelIndexMap.delete(level)
115
+ })
116
+ })
117
+
118
+ const levelIndexMap =
119
+ levelIndexMaps.get(block.listItem) ?? new Map<number, number>()
120
+ const levelCounter = levelIndexMap.get(block.level) ?? 0
121
+ levelIndexMap.set(block.level, levelCounter + 1)
122
+ levelIndexMaps.set(block.listItem, levelIndexMap)
123
+
124
+ listIndexMap.set(block._key, levelCounter + 1)
125
+
126
+ previousListItem = {
127
+ listItem: block.listItem,
128
+ level: block.level,
129
+ }
130
+ }
131
+
132
+ return listIndexMap
133
+ }
@@ -0,0 +1,135 @@
1
+ import type {
2
+ ArbitraryTypedObject,
3
+ PortableTextBlock,
4
+ TypedObject,
5
+ } from '@portabletext/types'
6
+ import {buildListIndexMap} from './build-list-index-map'
7
+ import {createRenderNode} from './render-node'
8
+ import {
9
+ DefaultBlockSpacingRenderer,
10
+ type BlockSpacingRenderer,
11
+ } from './renderers/block-spacing'
12
+ import {DefaultHardBreakRenderer} from './renderers/hard-break'
13
+ import {
14
+ DefaultListItemRenderer,
15
+ DefaultUnknownListItemRenderer,
16
+ } from './renderers/list-item'
17
+ import {
18
+ DefaultCodeRenderer,
19
+ DefaultEmRenderer,
20
+ DefaultLinkRenderer,
21
+ DefaultStrikeThroughRenderer,
22
+ DefaultStrongRenderer,
23
+ DefaultUnderlineRenderer,
24
+ DefaultUnknownMarkRenderer,
25
+ } from './renderers/marks'
26
+ import {
27
+ DefaultBlockquoteRenderer,
28
+ DefaultH1Renderer,
29
+ DefaultH2Renderer,
30
+ DefaultH3Renderer,
31
+ DefaultH4Renderer,
32
+ DefaultH5Renderer,
33
+ DefaultH6Renderer,
34
+ DefaultNormalRenderer,
35
+ DefaultUnknownStyleRenderer,
36
+ } from './renderers/style'
37
+ import {DefaultUnknownTypeRenderer} from './renderers/type'
38
+ import type {PortableTextRenderers} from './types'
39
+
40
+ const defaultRenderers: PortableTextRenderers = {
41
+ types: {},
42
+
43
+ block: {
44
+ normal: DefaultNormalRenderer,
45
+ blockquote: DefaultBlockquoteRenderer,
46
+ h1: DefaultH1Renderer,
47
+ h2: DefaultH2Renderer,
48
+ h3: DefaultH3Renderer,
49
+ h4: DefaultH4Renderer,
50
+ h5: DefaultH5Renderer,
51
+ h6: DefaultH6Renderer,
52
+ },
53
+ marks: {
54
+ 'em': DefaultEmRenderer,
55
+ 'strong': DefaultStrongRenderer,
56
+ 'code': DefaultCodeRenderer,
57
+ 'underline': DefaultUnderlineRenderer,
58
+ 'strike-through': DefaultStrikeThroughRenderer,
59
+ 'link': DefaultLinkRenderer,
60
+ },
61
+ listItem: DefaultListItemRenderer,
62
+ hardBreak: DefaultHardBreakRenderer,
63
+
64
+ unknownType: DefaultUnknownTypeRenderer,
65
+ unknownMark: DefaultUnknownMarkRenderer,
66
+ unknownListItem: DefaultUnknownListItemRenderer,
67
+ unknownBlockStyle: DefaultUnknownStyleRenderer,
68
+ }
69
+
70
+ type Options = Partial<PortableTextRenderers> & {
71
+ blockSpacing?: BlockSpacingRenderer
72
+ }
73
+
74
+ /**
75
+ * @public
76
+ */
77
+ export function portableTextToMarkdown<
78
+ Block extends TypedObject = PortableTextBlock | ArbitraryTypedObject,
79
+ >(blocks: Array<Block>, options: Options = {}): string {
80
+ const renderers = {
81
+ block: {
82
+ ...defaultRenderers.block,
83
+ ...options.block,
84
+ },
85
+ listItem: options.listItem ?? defaultRenderers.listItem,
86
+ marks: {
87
+ ...defaultRenderers.marks,
88
+ ...options.marks,
89
+ },
90
+ types: {
91
+ ...defaultRenderers.types,
92
+ ...options.types,
93
+ },
94
+ hardBreak: options.hardBreak ?? defaultRenderers.hardBreak,
95
+ unknownType: options.unknownType ?? defaultRenderers.unknownType,
96
+ unknownBlockStyle:
97
+ options.unknownBlockStyle ?? defaultRenderers.unknownBlockStyle,
98
+ unknownListItem:
99
+ options.unknownListItem ?? defaultRenderers.unknownListItem,
100
+ unknownMark: options.unknownMark ?? defaultRenderers.unknownMark,
101
+ }
102
+ const renderBlockSpacing = options.blockSpacing ?? DefaultBlockSpacingRenderer
103
+
104
+ const listIndexMap = buildListIndexMap(blocks)
105
+ const renderNode = createRenderNode(renderers, listIndexMap)
106
+
107
+ return blocks
108
+ .map((node, index) => {
109
+ const renderedNode = renderNode({
110
+ node,
111
+ index,
112
+ isInline: false,
113
+ renderNode,
114
+ })
115
+
116
+ if (index === blocks.length - 1) {
117
+ return renderedNode
118
+ }
119
+
120
+ const nextNode = blocks.at(index + 1)
121
+
122
+ if (!nextNode) {
123
+ return renderedNode
124
+ }
125
+
126
+ const blockSpacing =
127
+ renderBlockSpacing({
128
+ current: node,
129
+ next: nextNode,
130
+ }) ?? '\n\n'
131
+
132
+ return `${renderedNode}${blockSpacing}`
133
+ })
134
+ .join('')
135
+ }
@@ -0,0 +1,176 @@
1
+ import {
2
+ buildMarksTree,
3
+ isPortableTextBlock,
4
+ isPortableTextListItemBlock,
5
+ isPortableTextToolkitSpan,
6
+ isPortableTextToolkitTextNode,
7
+ spanToPlainText,
8
+ type ToolkitNestedPortableTextSpan,
9
+ type ToolkitTextNode,
10
+ } from '@portabletext/toolkit'
11
+ import type {
12
+ PortableTextBlock,
13
+ PortableTextListItemBlock,
14
+ PortableTextMarkDefinition,
15
+ PortableTextSpan,
16
+ TypedObject,
17
+ } from '@portabletext/types'
18
+ import {defaultKeyGenerator} from '../key-generator'
19
+ import type {PortableTextRenderers, RenderNode, Serializable} from './types'
20
+
21
+ interface SerializedBlock {
22
+ _key: string
23
+ children: string
24
+ index: number
25
+ isInline: boolean
26
+ node: PortableTextBlock | PortableTextListItemBlock
27
+ }
28
+
29
+ export const createRenderNode = (
30
+ renderers: PortableTextRenderers,
31
+ listIndexMap: Map<string, number>,
32
+ ): RenderNode => {
33
+ function renderNode<N extends TypedObject>(options: Serializable<N>): string {
34
+ const {node, index, isInline} = options
35
+
36
+ if (isPortableTextListItemBlock(node)) {
37
+ return renderListItem(node, index)
38
+ }
39
+
40
+ if (isPortableTextToolkitSpan(node)) {
41
+ return renderSpan(node)
42
+ }
43
+
44
+ if (isPortableTextBlock(node)) {
45
+ return renderBlock(node, index, isInline)
46
+ }
47
+
48
+ if (isPortableTextToolkitTextNode(node)) {
49
+ return renderText(node)
50
+ }
51
+
52
+ return renderCustomBlock(node, index, isInline)
53
+ }
54
+
55
+ function renderListItem(
56
+ node: PortableTextListItemBlock<
57
+ PortableTextMarkDefinition,
58
+ PortableTextSpan
59
+ >,
60
+ index: number,
61
+ ): string {
62
+ const renderer = renderers.listItem
63
+ const handler =
64
+ typeof renderer === 'function' ? renderer : renderer[node.listItem]
65
+ const itemHandler = handler || renderers.unknownListItem
66
+
67
+ // Build the text content from the block
68
+ const tree = buildMarksTree(node)
69
+ const textContent = tree
70
+ .map((child, i) => {
71
+ return renderNode({node: child, isInline: true, index: i, renderNode})
72
+ })
73
+ .join('')
74
+
75
+ let children = textContent
76
+
77
+ if (node.style && node.style !== 'normal') {
78
+ // Wrap any other style in whatever the block component says to use
79
+ const {listItem: _listItem, ...blockNode} = node
80
+ children = renderNode({
81
+ node: blockNode,
82
+ index,
83
+ isInline: false,
84
+ renderNode,
85
+ })
86
+ // Strip trailing newlines from block styles - list item component handles spacing
87
+ children = children.replace(/\n+$/, '')
88
+ }
89
+
90
+ return itemHandler({
91
+ value: node,
92
+ index,
93
+ listIndex: node._key ? listIndexMap.get(node._key) : undefined,
94
+ isInline: false,
95
+ renderNode,
96
+ children,
97
+ })
98
+ }
99
+
100
+ function renderSpan(node: ToolkitNestedPortableTextSpan): string {
101
+ const {markDef, markType, markKey} = node
102
+ const span = renderers.marks[markType] || renderers.unknownMark
103
+ const children = node.children.map((child, childIndex) =>
104
+ renderNode({node: child, index: childIndex, isInline: true, renderNode}),
105
+ )
106
+
107
+ return span({
108
+ text: spanToPlainText(node),
109
+ value: markDef,
110
+ markType,
111
+ markKey,
112
+ renderNode,
113
+ children: children.join(''),
114
+ })
115
+ }
116
+
117
+ function renderBlock(
118
+ node: PortableTextBlock,
119
+ index: number,
120
+ isInline: boolean,
121
+ ): string {
122
+ const {_key, ...props} = serializeBlock({node, index, isInline, renderNode})
123
+ const style = props.node.style || 'normal'
124
+ const handler =
125
+ typeof renderers.block === 'function'
126
+ ? renderers.block
127
+ : renderers.block[style]
128
+ const block = handler || renderers.unknownBlockStyle
129
+
130
+ return block({...props, value: props.node, renderNode})
131
+ }
132
+
133
+ function renderText(node: ToolkitTextNode): string {
134
+ if (node.text === '\n') {
135
+ return renderers.hardBreak()
136
+ }
137
+
138
+ return node.text
139
+ }
140
+
141
+ function renderCustomBlock(
142
+ value: TypedObject,
143
+ index: number,
144
+ isInline: boolean,
145
+ ): string {
146
+ const component = renderers.types[value._type] ?? renderers.unknownType
147
+
148
+ return component({
149
+ value,
150
+ isInline,
151
+ index,
152
+ renderNode,
153
+ })
154
+ }
155
+
156
+ return renderNode
157
+ }
158
+
159
+ function serializeBlock(
160
+ options: Serializable<PortableTextBlock>,
161
+ ): SerializedBlock {
162
+ const {node, index, isInline, renderNode} = options
163
+ const tree = buildMarksTree(node)
164
+
165
+ const renderedChildren = tree.map((child, i) =>
166
+ renderNode({node: child, isInline: true, index: i, renderNode}),
167
+ )
168
+
169
+ return {
170
+ _key: node._key || defaultKeyGenerator(),
171
+ children: renderedChildren.join(''),
172
+ index,
173
+ isInline,
174
+ node,
175
+ }
176
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ isPortableTextBlock,
3
+ isPortableTextListItemBlock,
4
+ } from '@portabletext/toolkit'
5
+ import type {TypedObject} from '@portabletext/types'
6
+
7
+ /**
8
+ * @public
9
+ */
10
+ export type BlockSpacingRenderer = (options: {
11
+ current: TypedObject
12
+ next: TypedObject
13
+ }) => string | undefined
14
+
15
+ /**
16
+ * @public
17
+ */
18
+ export const DefaultBlockSpacingRenderer: BlockSpacingRenderer = ({
19
+ current,
20
+ next,
21
+ }) => {
22
+ if (
23
+ isPortableTextListItemBlock(current) &&
24
+ isPortableTextListItemBlock(next)
25
+ ) {
26
+ return '\n'
27
+ }
28
+
29
+ if (
30
+ isPortableTextBlock(current) &&
31
+ isPortableTextBlock(next) &&
32
+ current.style === 'blockquote' &&
33
+ next.style === 'blockquote'
34
+ ) {
35
+ return '\n>\n'
36
+ }
37
+
38
+ return '\n\n'
39
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @public
3
+ */
4
+ export const DefaultHardBreakRenderer = (): string => ' \n'
@@ -0,0 +1,32 @@
1
+ import type {PortableTextListItemRenderer} from '../types'
2
+
3
+ /**
4
+ * @public
5
+ */
6
+ export const DefaultListItemRenderer: PortableTextListItemRenderer = ({
7
+ children,
8
+ value,
9
+ listIndex,
10
+ }) => {
11
+ const listStyle = value.listItem || 'bullet'
12
+ const level = value.level || 1
13
+
14
+ if (listStyle === 'number') {
15
+ const indent = ' '.repeat(level - 1)
16
+
17
+ return `${indent}${listIndex ?? 1}. ${children}`
18
+ }
19
+
20
+ const indent = ' '.repeat(level - 1)
21
+
22
+ return `${indent}- ${children}`
23
+ }
24
+
25
+ /**
26
+ * @public
27
+ */
28
+ export const DefaultUnknownListItemRenderer: PortableTextListItemRenderer = ({
29
+ children,
30
+ }) => {
31
+ return `- ${children}\n`
32
+ }
@@ -0,0 +1,113 @@
1
+ import type {TypedObject} from '@portabletext/types'
2
+ import type {PortableTextMarkRenderer} from '../types'
3
+
4
+ /**
5
+ * @public
6
+ */
7
+ export const DefaultEmRenderer: PortableTextMarkRenderer = ({children}) =>
8
+ `_${children}_`
9
+
10
+ /**
11
+ * @public
12
+ */
13
+ export const DefaultStrongRenderer: PortableTextMarkRenderer = ({children}) =>
14
+ `**${children}**`
15
+
16
+ /**
17
+ * @public
18
+ */
19
+ export const DefaultCodeRenderer: PortableTextMarkRenderer = ({children}) =>
20
+ `\`${children}\``
21
+
22
+ /**
23
+ * @public
24
+ */
25
+ export const DefaultUnderlineRenderer: PortableTextMarkRenderer = ({
26
+ children,
27
+ }) => `<u>${children}</u>`
28
+
29
+ /**
30
+ * @public
31
+ */
32
+ export const DefaultStrikeThroughRenderer: PortableTextMarkRenderer = ({
33
+ children,
34
+ }) => `~~${children}~~`
35
+
36
+ interface DefaultLink extends TypedObject {
37
+ _type: 'link'
38
+ href: string
39
+ title: string | undefined
40
+ }
41
+
42
+ /**
43
+ * @public
44
+ */
45
+ export const DefaultLinkRenderer: PortableTextMarkRenderer<DefaultLink> = ({
46
+ children,
47
+ value,
48
+ }) => {
49
+ const href = value?.href || ''
50
+ const title = value?.title || ''
51
+ const looksSafe = uriLooksSafe(href)
52
+
53
+ if (looksSafe) {
54
+ // Check if the URL looks like an HTML injection attempt
55
+ // If it has quotes AND angle brackets, or other suspicious patterns, encode more aggressively
56
+ const looksLikeInjection = /["'][^"']*[<>]|[<>][^<>]*["']/.test(href)
57
+
58
+ if (looksLikeInjection) {
59
+ // Encode all special characters that could be used for injection
60
+ const encodedHref = href.replace(/["<>() ]/g, (char) => {
61
+ return `%${char.charCodeAt(0).toString(16).toUpperCase()}`
62
+ })
63
+ return `[${children}](${encodedHref})`
64
+ }
65
+
66
+ // For normal URLs, don't encode parentheses - Markdown handles balanced parens fine
67
+ return `[${children}](${href}${title ? ` "${title}"` : ''})`
68
+ }
69
+
70
+ // Return children without link when URL is unsafe
71
+ return children
72
+ }
73
+
74
+ function uriLooksSafe(uri: string): boolean {
75
+ const url = (uri || '').trim()
76
+ const first = url.charAt(0)
77
+
78
+ if (first === '#' || first === '/') {
79
+ return true
80
+ }
81
+
82
+ const colonIndex = url.indexOf(':')
83
+ if (colonIndex === -1) {
84
+ return true
85
+ }
86
+
87
+ const allowedProtocols = ['http', 'https', 'mailto', 'tel']
88
+ const proto = url.slice(0, colonIndex).toLowerCase()
89
+ if (allowedProtocols.indexOf(proto) !== -1) {
90
+ return true
91
+ }
92
+
93
+ const queryIndex = url.indexOf('?')
94
+ if (queryIndex !== -1 && colonIndex > queryIndex) {
95
+ return true
96
+ }
97
+
98
+ const hashIndex = url.indexOf('#')
99
+ if (hashIndex !== -1 && colonIndex > hashIndex) {
100
+ return true
101
+ }
102
+
103
+ return false
104
+ }
105
+
106
+ /**
107
+ * @public
108
+ */
109
+ export const DefaultUnknownMarkRenderer: PortableTextMarkRenderer = ({
110
+ children,
111
+ }) => {
112
+ return children
113
+ }
@@ -0,0 +1,79 @@
1
+ import type {PortableTextBlock} from '@portabletext/types'
2
+ import type {PortableTextRenderer} from '../types'
3
+
4
+ type PortableTextBlockRenderer = PortableTextRenderer<PortableTextBlock>
5
+
6
+ /**
7
+ * @public
8
+ */
9
+ export const DefaultNormalRenderer: PortableTextBlockRenderer = ({
10
+ children,
11
+ }) => {
12
+ // Empty blocks should not add extra spacing
13
+ if (!children || children.trim() === '') {
14
+ return ''
15
+ }
16
+
17
+ return children
18
+ }
19
+
20
+ /**
21
+ * @public
22
+ */
23
+ export const DefaultBlockquoteRenderer: PortableTextBlockRenderer = ({
24
+ children,
25
+ }) => {
26
+ // Prefix each line with "> " for proper blockquote formatting
27
+ // This handles multi-line content and preserves empty lines
28
+ if (!children) return '>'
29
+
30
+ return children
31
+ .split('\n')
32
+ .map((line) => `> ${line}`)
33
+ .join('\n')
34
+ }
35
+
36
+ /**
37
+ * @public
38
+ */
39
+ export const DefaultH1Renderer: PortableTextBlockRenderer = ({children}) =>
40
+ `# ${children}`
41
+
42
+ /**
43
+ * @public
44
+ */
45
+ export const DefaultH2Renderer: PortableTextBlockRenderer = ({children}) =>
46
+ `## ${children}`
47
+
48
+ /**
49
+ * @public
50
+ */
51
+ export const DefaultH3Renderer: PortableTextBlockRenderer = ({children}) =>
52
+ `### ${children}`
53
+
54
+ /**
55
+ * @public
56
+ */
57
+ export const DefaultH4Renderer: PortableTextBlockRenderer = ({children}) =>
58
+ `#### ${children}`
59
+
60
+ /**
61
+ * @public
62
+ */
63
+ export const DefaultH5Renderer: PortableTextBlockRenderer = ({children}) =>
64
+ `##### ${children}`
65
+
66
+ /**
67
+ * @public
68
+ */
69
+ export const DefaultH6Renderer: PortableTextBlockRenderer = ({children}) =>
70
+ `###### ${children}`
71
+
72
+ /**
73
+ * @public
74
+ */
75
+ export const DefaultUnknownStyleRenderer: PortableTextBlockRenderer = ({
76
+ children,
77
+ }) => {
78
+ return children ?? ''
79
+ }