@tiptap/markdown 3.20.2 → 3.20.3
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/package.json +5 -5
- package/src/MarkdownManager.ts +110 -31
- package/src/utils.ts +8 -6
- package/dist/index.cjs +0 -1054
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -285
- package/dist/index.d.ts +0 -285
- package/dist/index.js +0 -1054
- package/dist/index.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiptap/markdown",
|
|
3
3
|
"description": "markdown parser and serializer for tiptap",
|
|
4
|
-
"version": "3.20.
|
|
4
|
+
"version": "3.20.3",
|
|
5
5
|
"homepage": "https://tiptap.dev",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"tiptap",
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"marked": "^17.0.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@tiptap/core": "^3.20.
|
|
41
|
-
"@tiptap/pm": "^3.20.
|
|
40
|
+
"@tiptap/core": "^3.20.3",
|
|
41
|
+
"@tiptap/pm": "^3.20.3"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@tiptap/core": "^3.20.
|
|
45
|
-
"@tiptap/pm": "^3.20.
|
|
44
|
+
"@tiptap/core": "^3.20.3",
|
|
45
|
+
"@tiptap/pm": "^3.20.3"
|
|
46
46
|
},
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
package/src/MarkdownManager.ts
CHANGED
|
@@ -114,6 +114,7 @@ export class MarkdownManager {
|
|
|
114
114
|
// extensions to provide `markdown: { name?, parseName?, renderName?, parse?, render?, match? }`.
|
|
115
115
|
const markdownCfg = (getExtensionField(extension, 'markdownOptions') ?? null) as ExtendableConfig['markdownOptions']
|
|
116
116
|
const isIndenting = markdownCfg?.indentsContent ?? false
|
|
117
|
+
const htmlReopen = markdownCfg?.htmlReopen
|
|
117
118
|
|
|
118
119
|
const spec: MarkdownExtensionSpec = {
|
|
119
120
|
tokenName,
|
|
@@ -121,6 +122,7 @@ export class MarkdownManager {
|
|
|
121
122
|
parseMarkdown,
|
|
122
123
|
renderMarkdown,
|
|
123
124
|
isIndenting,
|
|
125
|
+
htmlReopen,
|
|
124
126
|
tokenizer,
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -629,7 +631,6 @@ export class MarkdownManager {
|
|
|
629
631
|
const token = tokens[i]
|
|
630
632
|
|
|
631
633
|
if (token.type === 'text') {
|
|
632
|
-
// Create text node
|
|
633
634
|
result.push({
|
|
634
635
|
type: 'text',
|
|
635
636
|
text: token.text || '',
|
|
@@ -970,6 +971,8 @@ export class MarkdownManager {
|
|
|
970
971
|
): string {
|
|
971
972
|
const result: string[] = []
|
|
972
973
|
const activeMarks: Map<string, any> = new Map()
|
|
974
|
+
const reopenWithHtmlOnNextOpen = new Set<string>()
|
|
975
|
+
const markOpeningModes = new Map<string, 'markdown' | 'html'>()
|
|
973
976
|
nodes.forEach((node, i) => {
|
|
974
977
|
// Lookahead to the next node to determine if marks need to be closed
|
|
975
978
|
const nextNode = i < nodes.length - 1 ? nodes[i + 1] : null
|
|
@@ -986,9 +989,23 @@ export class MarkdownManager {
|
|
|
986
989
|
const marksToOpen = findMarksToOpen(activeMarks, currentMarks)
|
|
987
990
|
const marksToClose = findMarksToClose(currentMarks, nextNode)
|
|
988
991
|
|
|
992
|
+
// When marks simultaneously close (old) AND open (new) at this boundary, the naive
|
|
993
|
+
// approach of appending old-close and prepending new-open produces interleaved
|
|
994
|
+
// delimiters like `*456**` (italic open, text, bold close) instead of properly
|
|
995
|
+
// nested `_456_**` (italic open, text, italic close, bold close).
|
|
996
|
+
//
|
|
997
|
+
// The fix: when both are present, defer old mark closings to the end of the node
|
|
998
|
+
// (after the new marks also close), ensuring correct inner-before-outer order.
|
|
999
|
+
//
|
|
1000
|
+
// If an already-active mark ends on this node while another mark opens on this same
|
|
1001
|
+
// node, we defer closing the active mark until the end of the node so nesting stays
|
|
1002
|
+
// valid (`**...++abc++**` instead of `**...++abc**++`).
|
|
1003
|
+
const activeMarksClosingHere = marksToClose.filter(markType => activeMarks.has(markType))
|
|
1004
|
+
const hasCrossedBoundary = activeMarksClosingHere.length > 0 && marksToOpen.length > 0
|
|
1005
|
+
|
|
989
1006
|
let middleTrailingWhitespace = ''
|
|
990
1007
|
|
|
991
|
-
if (marksToClose.length > 0) {
|
|
1008
|
+
if (marksToClose.length > 0 && !hasCrossedBoundary) {
|
|
992
1009
|
// Extract trailing whitespace before closing marks to prevent invalid markdown like "**text **"
|
|
993
1010
|
const middleTrailingMatch = textContent.match(/(\s+)$/)
|
|
994
1011
|
if (middleTrailingMatch) {
|
|
@@ -996,18 +1013,25 @@ export class MarkdownManager {
|
|
|
996
1013
|
textContent = textContent.slice(0, -middleTrailingWhitespace.length)
|
|
997
1014
|
}
|
|
998
1015
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1016
|
+
|
|
1017
|
+
if (!hasCrossedBoundary) {
|
|
1018
|
+
// Normal path: close marks that are ending here (no new marks opening simultaneously)
|
|
1019
|
+
marksToClose.forEach(markType => {
|
|
1020
|
+
if (!activeMarks.has(markType)) {
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const mark = currentMarks.get(markType)
|
|
1025
|
+
const closeMarkdown = this.getMarkClosing(markType, mark, markOpeningModes.get(markType))
|
|
1026
|
+
if (closeMarkdown) {
|
|
1027
|
+
textContent += closeMarkdown
|
|
1028
|
+
}
|
|
1029
|
+
if (activeMarks.has(markType)) {
|
|
1030
|
+
activeMarks.delete(markType)
|
|
1031
|
+
markOpeningModes.delete(markType)
|
|
1032
|
+
}
|
|
1033
|
+
})
|
|
1034
|
+
}
|
|
1011
1035
|
|
|
1012
1036
|
// Open new marks (should be at the beginning)
|
|
1013
1037
|
// Extract leading whitespace before opening marks to prevent invalid markdown like "** text**"
|
|
@@ -1020,26 +1044,53 @@ export class MarkdownManager {
|
|
|
1020
1044
|
}
|
|
1021
1045
|
}
|
|
1022
1046
|
|
|
1047
|
+
// Snapshot active mark types before opening new marks, so each new mark's delimiter
|
|
1048
|
+
// is chosen based on what is already active (not including itself).
|
|
1049
|
+
// When crossing a boundary, old marks are still in activeMarks here (not yet removed),
|
|
1050
|
+
// so new marks correctly see them as active context.
|
|
1023
1051
|
marksToOpen.forEach(({ type, mark }) => {
|
|
1024
|
-
const
|
|
1052
|
+
const openingMode = reopenWithHtmlOnNextOpen.has(type) ? 'html' : 'markdown'
|
|
1053
|
+
const openMarkdown = this.getMarkOpening(type, mark, openingMode)
|
|
1025
1054
|
if (openMarkdown) {
|
|
1026
1055
|
textContent = openMarkdown + textContent
|
|
1027
1056
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
}
|
|
1057
|
+
markOpeningModes.set(type, openingMode)
|
|
1058
|
+
reopenWithHtmlOnNextOpen.delete(type)
|
|
1031
1059
|
})
|
|
1032
1060
|
|
|
1061
|
+
if (!hasCrossedBoundary) {
|
|
1062
|
+
marksToOpen
|
|
1063
|
+
.slice()
|
|
1064
|
+
.reverse()
|
|
1065
|
+
.forEach(({ type, mark }) => {
|
|
1066
|
+
activeMarks.set(type, mark)
|
|
1067
|
+
})
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1033
1070
|
// Add leading whitespace before the mark opening
|
|
1034
1071
|
textContent = leadingWhitespace + textContent
|
|
1035
1072
|
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1073
|
+
// Determine marks to close at the end of this node.
|
|
1074
|
+
// On a crossed boundary, we close new marks (inner) first, then old marks (outer),
|
|
1075
|
+
// ensuring correct nesting order. Both sets are removed from activeMarks so the
|
|
1076
|
+
// next node's marksToOpen will reopen whichever ones continue.
|
|
1077
|
+
let marksToCloseAtEnd: string[]
|
|
1078
|
+
if (hasCrossedBoundary) {
|
|
1079
|
+
const nextMarkTypes = new Set((nextNode?.marks || []).map((mark: any) => mark.type))
|
|
1080
|
+
|
|
1081
|
+
marksToOpen.forEach(({ type }) => {
|
|
1082
|
+
if (nextMarkTypes.has(type) && this.getHtmlReopenTags(type)) {
|
|
1083
|
+
reopenWithHtmlOnNextOpen.add(type)
|
|
1084
|
+
}
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
marksToCloseAtEnd = [
|
|
1088
|
+
...marksToOpen.map(m => m.type), // inner (opened here) — close first
|
|
1089
|
+
...activeMarksClosingHere, // outer (were active before) — close last
|
|
1090
|
+
]
|
|
1091
|
+
} else {
|
|
1092
|
+
marksToCloseAtEnd = findMarksToCloseAtEnd(activeMarks, currentMarks, nextNode, this.markSetsEqual.bind(this))
|
|
1093
|
+
}
|
|
1043
1094
|
|
|
1044
1095
|
// Extract trailing whitespace before closing marks to prevent invalid markdown like "**text **"
|
|
1045
1096
|
let trailingWhitespace = ''
|
|
@@ -1052,12 +1103,13 @@ export class MarkdownManager {
|
|
|
1052
1103
|
}
|
|
1053
1104
|
|
|
1054
1105
|
marksToCloseAtEnd.forEach(markType => {
|
|
1055
|
-
const mark = activeMarks.get(markType)
|
|
1056
|
-
const closeMarkdown = this.getMarkClosing(markType, mark)
|
|
1106
|
+
const mark = activeMarks.get(markType) ?? currentMarks.get(markType)
|
|
1107
|
+
const closeMarkdown = this.getMarkClosing(markType, mark, markOpeningModes.get(markType))
|
|
1057
1108
|
if (closeMarkdown) {
|
|
1058
1109
|
textContent += closeMarkdown
|
|
1059
1110
|
}
|
|
1060
1111
|
activeMarks.delete(markType)
|
|
1112
|
+
markOpeningModes.delete(markType)
|
|
1061
1113
|
})
|
|
1062
1114
|
|
|
1063
1115
|
// Add trailing whitespace after the mark closing
|
|
@@ -1068,9 +1120,13 @@ export class MarkdownManager {
|
|
|
1068
1120
|
} else {
|
|
1069
1121
|
// For non-text nodes, close all active marks before rendering, then reopen after
|
|
1070
1122
|
const marksToReopen = new Map(activeMarks)
|
|
1123
|
+
const openingModesToReopen = new Map(markOpeningModes)
|
|
1071
1124
|
|
|
1072
1125
|
// Close all marks before the node
|
|
1073
|
-
const beforeMarkdown = closeMarksBeforeNode(activeMarks,
|
|
1126
|
+
const beforeMarkdown = closeMarksBeforeNode(activeMarks, (markType, mark) => {
|
|
1127
|
+
return this.getMarkClosing(markType, mark, markOpeningModes.get(markType))
|
|
1128
|
+
})
|
|
1129
|
+
markOpeningModes.clear()
|
|
1074
1130
|
|
|
1075
1131
|
// Render the node
|
|
1076
1132
|
const nodeContent = this.renderNodeToMarkdown(node, parentNode, i, level)
|
|
@@ -1080,7 +1136,11 @@ export class MarkdownManager {
|
|
|
1080
1136
|
const afterMarkdown =
|
|
1081
1137
|
node.type === 'hardBreak'
|
|
1082
1138
|
? ''
|
|
1083
|
-
: reopenMarksAfterNode(marksToReopen, activeMarks,
|
|
1139
|
+
: reopenMarksAfterNode(marksToReopen, activeMarks, (markType, mark) => {
|
|
1140
|
+
const openingMode = openingModesToReopen.get(markType) ?? 'markdown'
|
|
1141
|
+
markOpeningModes.set(markType, openingMode)
|
|
1142
|
+
return this.getMarkOpening(markType, mark, openingMode)
|
|
1143
|
+
})
|
|
1084
1144
|
|
|
1085
1145
|
result.push(beforeMarkdown + nodeContent + afterMarkdown)
|
|
1086
1146
|
}
|
|
@@ -1092,7 +1152,11 @@ export class MarkdownManager {
|
|
|
1092
1152
|
/**
|
|
1093
1153
|
* Get the opening markdown syntax for a mark type.
|
|
1094
1154
|
*/
|
|
1095
|
-
private getMarkOpening(markType: string, mark: any): string {
|
|
1155
|
+
private getMarkOpening(markType: string, mark: any, openingMode: 'markdown' | 'html' = 'markdown'): string {
|
|
1156
|
+
if (openingMode === 'html') {
|
|
1157
|
+
return this.getHtmlReopenTags(markType)?.open || ''
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1096
1160
|
const handlers = this.getHandlersForNodeType(markType)
|
|
1097
1161
|
const handler = handlers.length > 0 ? handlers[0] : undefined
|
|
1098
1162
|
if (!handler || !handler.renderMarkdown) {
|
|
@@ -1132,7 +1196,11 @@ export class MarkdownManager {
|
|
|
1132
1196
|
/**
|
|
1133
1197
|
* Get the closing markdown syntax for a mark type.
|
|
1134
1198
|
*/
|
|
1135
|
-
private getMarkClosing(markType: string, mark: any): string {
|
|
1199
|
+
private getMarkClosing(markType: string, mark: any, openingMode: 'markdown' | 'html' = 'markdown'): string {
|
|
1200
|
+
if (openingMode === 'html') {
|
|
1201
|
+
return this.getHtmlReopenTags(markType)?.close || ''
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1136
1204
|
const handlers = this.getHandlersForNodeType(markType)
|
|
1137
1205
|
const handler = handlers.length > 0 ? handlers[0] : undefined
|
|
1138
1206
|
if (!handler || !handler.renderMarkdown) {
|
|
@@ -1169,6 +1237,17 @@ export class MarkdownManager {
|
|
|
1169
1237
|
}
|
|
1170
1238
|
}
|
|
1171
1239
|
|
|
1240
|
+
/**
|
|
1241
|
+
* Returns the inline HTML tags an extension exposes for overlap-boundary
|
|
1242
|
+
* reopen handling, if that mark explicitly opted into HTML reopen mode.
|
|
1243
|
+
*/
|
|
1244
|
+
private getHtmlReopenTags(markType: string): { open: string; close: string } | undefined {
|
|
1245
|
+
const handlers = this.getHandlersForNodeType(markType)
|
|
1246
|
+
const handler = handlers.length > 0 ? handlers[0] : undefined
|
|
1247
|
+
|
|
1248
|
+
return handler?.htmlReopen
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1172
1251
|
/**
|
|
1173
1252
|
* Check if two mark sets are equal.
|
|
1174
1253
|
*/
|
package/src/utils.ts
CHANGED
|
@@ -77,14 +77,16 @@ export function findMarksToCloseAtEnd(
|
|
|
77
77
|
if (isLastNode || nextNodeHasNoMarks || nextNodeHasDifferentMarks) {
|
|
78
78
|
if (nextNode && nextNode.type === 'text' && nextNode.marks) {
|
|
79
79
|
const nextMarks = new Map(nextNode.marks.map((mark: any) => [mark.type, mark]))
|
|
80
|
-
Array.from(activeMarks.keys())
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
Array.from(activeMarks.keys())
|
|
81
|
+
.reverse()
|
|
82
|
+
.forEach(markType => {
|
|
83
|
+
if (!nextMarks.has(markType)) {
|
|
84
|
+
marksToCloseAtEnd.push(markType)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
85
87
|
} else if (isLastNode || nextNodeHasNoMarks) {
|
|
86
88
|
// Close all active marks
|
|
87
|
-
marksToCloseAtEnd.push(...Array.from(activeMarks.keys()))
|
|
89
|
+
marksToCloseAtEnd.push(...Array.from(activeMarks.keys()).reverse())
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|