@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 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.2",
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.2",
41
- "@tiptap/pm": "^3.20.2"
40
+ "@tiptap/core": "^3.20.3",
41
+ "@tiptap/pm": "^3.20.3"
42
42
  },
43
43
  "peerDependencies": {
44
- "@tiptap/core": "^3.20.2",
45
- "@tiptap/pm": "^3.20.2"
44
+ "@tiptap/core": "^3.20.3",
45
+ "@tiptap/pm": "^3.20.3"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
@@ -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
- // Close marks that are ending here
1000
- marksToClose.forEach(markType => {
1001
- const mark = currentMarks.get(markType)
1002
- const closeMarkdown = this.getMarkClosing(markType, mark)
1003
- if (closeMarkdown) {
1004
- textContent += closeMarkdown
1005
- }
1006
- // deleting closed marks from active marks
1007
- if (activeMarks.has(markType)) {
1008
- activeMarks.delete(markType)
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 openMarkdown = this.getMarkOpening(type, mark)
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
- if (!marksToClose.includes(type)) {
1029
- activeMarks.set(type, mark)
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
- // Close marks at the end of this node if needed
1037
- const marksToCloseAtEnd = findMarksToCloseAtEnd(
1038
- activeMarks,
1039
- currentMarks,
1040
- nextNode,
1041
- this.markSetsEqual.bind(this),
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, this.getMarkClosing.bind(this))
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, this.getMarkOpening.bind(this))
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()).forEach(markType => {
81
- if (!nextMarks.has(markType)) {
82
- marksToCloseAtEnd.push(markType)
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