@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 +429 -0
- package/dist/index.d.ts +599 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1098 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/default-schema.ts +166 -0
- package/src/example-document.md +237 -0
- package/src/example-document.out.md +235 -0
- package/src/example-document.terse-pt.json +124 -0
- package/src/example-document.test.ts +87 -0
- package/src/from-portable-text/build-list-index-map.ts +133 -0
- package/src/from-portable-text/portable-text-to-markdown.ts +135 -0
- package/src/from-portable-text/render-node.ts +176 -0
- package/src/from-portable-text/renderers/block-spacing.ts +39 -0
- package/src/from-portable-text/renderers/hard-break.ts +4 -0
- package/src/from-portable-text/renderers/list-item.ts +32 -0
- package/src/from-portable-text/renderers/marks.ts +113 -0
- package/src/from-portable-text/renderers/style.ts +79 -0
- package/src/from-portable-text/renderers/type.ts +126 -0
- package/src/from-portable-text/types.ts +240 -0
- package/src/index.ts +51 -0
- package/src/key-generator.ts +32 -0
- package/src/markdown-to-portable-text.test.ts +3273 -0
- package/src/portable-text-to-markdown.test.ts +803 -0
- package/src/to-portable-text/markdown-to-portable-text.ts +1204 -0
- package/src/to-portable-text/matchers.ts +192 -0
|
@@ -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,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
|
+
}
|