@tiptap/markdown 3.20.1 → 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.1",
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.1",
41
- "@tiptap/pm": "^3.20.1"
40
+ "@tiptap/core": "^3.20.3",
41
+ "@tiptap/pm": "^3.20.3"
42
42
  },
43
43
  "peerDependencies": {
44
- "@tiptap/core": "^3.20.1",
45
- "@tiptap/pm": "^3.20.1"
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
 
@@ -291,7 +293,7 @@ export class MarkdownManager {
291
293
  const tokens = this.markedInstance.lexer(markdown)
292
294
 
293
295
  // Convert tokens to Tiptap JSON
294
- const content = this.parseTokens(tokens)
296
+ const content = this.parseTokens(tokens, true)
295
297
 
296
298
  // Return a document node containing the parsed content
297
299
  return {
@@ -303,17 +305,68 @@ export class MarkdownManager {
303
305
  /**
304
306
  * Convert an array of marked tokens into Tiptap JSON nodes using registered extension handlers.
305
307
  */
306
- private parseTokens(tokens: MarkdownToken[]): JSONContent[] {
307
- return tokens
308
- .map(token => this.parseToken(token))
309
- .filter((parsed): parsed is JSONContent | JSONContent[] => parsed !== null)
310
- .flatMap(parsed => (Array.isArray(parsed) ? parsed : [parsed]))
308
+ private parseTokens(tokens: MarkdownToken[], parseImplicitEmptyParagraphs = false): JSONContent[] {
309
+ const nonSpaceTokenIndexes = tokens.reduce<number[]>((indexes, token, index) => {
310
+ if (token.type !== 'space') {
311
+ indexes.push(index)
312
+ }
313
+
314
+ return indexes
315
+ }, [])
316
+
317
+ let previousNonSpaceTokenIndex = -1
318
+ let nextNonSpaceTokenPointer = 0
319
+
320
+ return tokens.flatMap((token, index) => {
321
+ while (
322
+ nextNonSpaceTokenPointer < nonSpaceTokenIndexes.length &&
323
+ nonSpaceTokenIndexes[nextNonSpaceTokenPointer] < index
324
+ ) {
325
+ previousNonSpaceTokenIndex = nonSpaceTokenIndexes[nextNonSpaceTokenPointer]
326
+ nextNonSpaceTokenPointer += 1
327
+ }
328
+
329
+ if (parseImplicitEmptyParagraphs && token.type === 'space') {
330
+ const nextNonSpaceTokenIndex = nonSpaceTokenIndexes[nextNonSpaceTokenPointer] ?? -1
331
+
332
+ return this.createImplicitEmptyParagraphsFromSpace(token, previousNonSpaceTokenIndex, nextNonSpaceTokenIndex)
333
+ }
334
+
335
+ const parsed = this.parseToken(token, parseImplicitEmptyParagraphs)
336
+
337
+ if (parsed === null) {
338
+ return []
339
+ }
340
+
341
+ return Array.isArray(parsed) ? parsed : [parsed]
342
+ })
343
+ }
344
+
345
+ private createImplicitEmptyParagraphsFromSpace(
346
+ token: MarkdownToken,
347
+ previousNonSpaceTokenIndex: number,
348
+ nextNonSpaceTokenIndex: number,
349
+ ): JSONContent[] {
350
+ const separatorCount = this.countParagraphSeparators(token.raw || '')
351
+
352
+ if (separatorCount === 0) {
353
+ return []
354
+ }
355
+
356
+ const isBoundarySpace = previousNonSpaceTokenIndex === -1 || nextNonSpaceTokenIndex === -1
357
+ const emptyParagraphCount = Math.max(separatorCount - (isBoundarySpace ? 0 : 1), 0)
358
+
359
+ return Array.from({ length: emptyParagraphCount }, () => ({ type: 'paragraph', content: [] }))
360
+ }
361
+
362
+ private countParagraphSeparators(raw: string): number {
363
+ return (raw.replace(/\r\n/g, '\n').match(/\n\n/g) || []).length
311
364
  }
312
365
 
313
366
  /**
314
367
  * Parse a single token into Tiptap JSON using the appropriate registered handler.
315
368
  */
316
- private parseToken(token: MarkdownToken): JSONContent | JSONContent[] | null {
369
+ private parseToken(token: MarkdownToken, parseImplicitEmptyParagraphs = false): JSONContent | JSONContent[] | null {
317
370
  if (!token.type) {
318
371
  return null
319
372
  }
@@ -353,7 +406,7 @@ export class MarkdownManager {
353
406
  }
354
407
 
355
408
  // If no handler worked, try fallback parsing
356
- return this.parseFallbackToken(token)
409
+ return this.parseFallbackToken(token, parseImplicitEmptyParagraphs)
357
410
  }
358
411
 
359
412
  private lastParseResult: JSONContent | JSONContent[] | null = null
@@ -527,6 +580,7 @@ export class MarkdownManager {
527
580
  return {
528
581
  parseInline: (tokens: MarkdownToken[]) => this.parseInlineTokens(tokens),
529
582
  parseChildren: (tokens: MarkdownToken[]) => this.parseTokens(tokens),
583
+ parseBlockChildren: (tokens: MarkdownToken[]) => this.parseTokens(tokens, true),
530
584
  createTextNode: (text: string, marks?: Array<{ type: string; attrs?: any }>) => {
531
585
  const node = {
532
586
  type: 'text',
@@ -577,7 +631,6 @@ export class MarkdownManager {
577
631
  const token = tokens[i]
578
632
 
579
633
  if (token.type === 'text') {
580
- // Create text node
581
634
  result.push({
582
635
  type: 'text',
583
636
  text: token.text || '',
@@ -723,7 +776,10 @@ export class MarkdownManager {
723
776
  /**
724
777
  * Fallback parsing for common tokens when no specific handler is registered.
725
778
  */
726
- private parseFallbackToken(token: MarkdownToken): JSONContent | JSONContent[] | null {
779
+ private parseFallbackToken(
780
+ token: MarkdownToken,
781
+ parseImplicitEmptyParagraphs = false,
782
+ ): JSONContent | JSONContent[] | null {
727
783
  switch (token.type) {
728
784
  case 'paragraph':
729
785
  return {
@@ -754,7 +810,7 @@ export class MarkdownManager {
754
810
  default:
755
811
  // Unknown token type - try to parse children if they exist
756
812
  if (token.tokens) {
757
- return this.parseTokens(token.tokens)
813
+ return this.parseTokens(token.tokens, parseImplicitEmptyParagraphs)
758
814
  }
759
815
  return null
760
816
  }
@@ -819,7 +875,13 @@ export class MarkdownManager {
819
875
  }
820
876
  }
821
877
 
822
- renderNodeToMarkdown(node: JSONContent, parentNode?: JSONContent, index = 0, level = 0): string {
878
+ renderNodeToMarkdown(
879
+ node: JSONContent,
880
+ parentNode?: JSONContent,
881
+ index = 0,
882
+ level = 0,
883
+ meta: Record<string, any> = {},
884
+ ): string {
823
885
  // if node is a text node, we simply return it's text content
824
886
  // marks are handled at the array level in renderNodesWithMarkBoundaries
825
887
  if (node.type === 'text') {
@@ -835,6 +897,7 @@ export class MarkdownManager {
835
897
  return ''
836
898
  }
837
899
 
900
+ const previousNode = Array.isArray(parentNode?.content) && index > 0 ? parentNode.content[index - 1] : undefined
838
901
  const helpers: MarkdownRendererHelpers = {
839
902
  renderChildren: (nodes, separator) => {
840
903
  const childLevel = handler.isIndenting ? level + 1 : level
@@ -845,6 +908,11 @@ export class MarkdownManager {
845
908
 
846
909
  return this.renderNodes(nodes, node, separator || '', index, childLevel)
847
910
  },
911
+ renderChild: (childNode, childIndex) => {
912
+ const childLevel = handler.isIndenting ? level + 1 : level
913
+
914
+ return this.renderNodeToMarkdown(childNode, node, childIndex, childLevel)
915
+ },
848
916
  indent: content => {
849
917
  return this.indentString + content
850
918
  },
@@ -855,8 +923,10 @@ export class MarkdownManager {
855
923
  index,
856
924
  level,
857
925
  parentType: parentNode?.type,
926
+ previousNode,
858
927
  meta: {
859
928
  parentAttrs: parentNode?.attrs,
929
+ ...meta,
860
930
  },
861
931
  }
862
932
 
@@ -901,6 +971,8 @@ export class MarkdownManager {
901
971
  ): string {
902
972
  const result: string[] = []
903
973
  const activeMarks: Map<string, any> = new Map()
974
+ const reopenWithHtmlOnNextOpen = new Set<string>()
975
+ const markOpeningModes = new Map<string, 'markdown' | 'html'>()
904
976
  nodes.forEach((node, i) => {
905
977
  // Lookahead to the next node to determine if marks need to be closed
906
978
  const nextNode = i < nodes.length - 1 ? nodes[i + 1] : null
@@ -917,9 +989,23 @@ export class MarkdownManager {
917
989
  const marksToOpen = findMarksToOpen(activeMarks, currentMarks)
918
990
  const marksToClose = findMarksToClose(currentMarks, nextNode)
919
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
+
920
1006
  let middleTrailingWhitespace = ''
921
1007
 
922
- if (marksToClose.length > 0) {
1008
+ if (marksToClose.length > 0 && !hasCrossedBoundary) {
923
1009
  // Extract trailing whitespace before closing marks to prevent invalid markdown like "**text **"
924
1010
  const middleTrailingMatch = textContent.match(/(\s+)$/)
925
1011
  if (middleTrailingMatch) {
@@ -927,18 +1013,25 @@ export class MarkdownManager {
927
1013
  textContent = textContent.slice(0, -middleTrailingWhitespace.length)
928
1014
  }
929
1015
  }
930
- // Close marks that are ending here
931
- marksToClose.forEach(markType => {
932
- const mark = currentMarks.get(markType)
933
- const closeMarkdown = this.getMarkClosing(markType, mark)
934
- if (closeMarkdown) {
935
- textContent += closeMarkdown
936
- }
937
- // deleting closed marks from active marks
938
- if (activeMarks.has(markType)) {
939
- activeMarks.delete(markType)
940
- }
941
- })
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
+ }
942
1035
 
943
1036
  // Open new marks (should be at the beginning)
944
1037
  // Extract leading whitespace before opening marks to prevent invalid markdown like "** text**"
@@ -951,26 +1044,53 @@ export class MarkdownManager {
951
1044
  }
952
1045
  }
953
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.
954
1051
  marksToOpen.forEach(({ type, mark }) => {
955
- const openMarkdown = this.getMarkOpening(type, mark)
1052
+ const openingMode = reopenWithHtmlOnNextOpen.has(type) ? 'html' : 'markdown'
1053
+ const openMarkdown = this.getMarkOpening(type, mark, openingMode)
956
1054
  if (openMarkdown) {
957
1055
  textContent = openMarkdown + textContent
958
1056
  }
959
- if (!marksToClose.includes(type)) {
960
- activeMarks.set(type, mark)
961
- }
1057
+ markOpeningModes.set(type, openingMode)
1058
+ reopenWithHtmlOnNextOpen.delete(type)
962
1059
  })
963
1060
 
1061
+ if (!hasCrossedBoundary) {
1062
+ marksToOpen
1063
+ .slice()
1064
+ .reverse()
1065
+ .forEach(({ type, mark }) => {
1066
+ activeMarks.set(type, mark)
1067
+ })
1068
+ }
1069
+
964
1070
  // Add leading whitespace before the mark opening
965
1071
  textContent = leadingWhitespace + textContent
966
1072
 
967
- // Close marks at the end of this node if needed
968
- const marksToCloseAtEnd = findMarksToCloseAtEnd(
969
- activeMarks,
970
- currentMarks,
971
- nextNode,
972
- this.markSetsEqual.bind(this),
973
- )
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
+ }
974
1094
 
975
1095
  // Extract trailing whitespace before closing marks to prevent invalid markdown like "**text **"
976
1096
  let trailingWhitespace = ''
@@ -983,12 +1103,13 @@ export class MarkdownManager {
983
1103
  }
984
1104
 
985
1105
  marksToCloseAtEnd.forEach(markType => {
986
- const mark = activeMarks.get(markType)
987
- 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))
988
1108
  if (closeMarkdown) {
989
1109
  textContent += closeMarkdown
990
1110
  }
991
1111
  activeMarks.delete(markType)
1112
+ markOpeningModes.delete(markType)
992
1113
  })
993
1114
 
994
1115
  // Add trailing whitespace after the mark closing
@@ -999,9 +1120,13 @@ export class MarkdownManager {
999
1120
  } else {
1000
1121
  // For non-text nodes, close all active marks before rendering, then reopen after
1001
1122
  const marksToReopen = new Map(activeMarks)
1123
+ const openingModesToReopen = new Map(markOpeningModes)
1002
1124
 
1003
1125
  // Close all marks before the node
1004
- 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()
1005
1130
 
1006
1131
  // Render the node
1007
1132
  const nodeContent = this.renderNodeToMarkdown(node, parentNode, i, level)
@@ -1011,7 +1136,11 @@ export class MarkdownManager {
1011
1136
  const afterMarkdown =
1012
1137
  node.type === 'hardBreak'
1013
1138
  ? ''
1014
- : 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
+ })
1015
1144
 
1016
1145
  result.push(beforeMarkdown + nodeContent + afterMarkdown)
1017
1146
  }
@@ -1023,7 +1152,11 @@ export class MarkdownManager {
1023
1152
  /**
1024
1153
  * Get the opening markdown syntax for a mark type.
1025
1154
  */
1026
- 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
+
1027
1160
  const handlers = this.getHandlersForNodeType(markType)
1028
1161
  const handler = handlers.length > 0 ? handlers[0] : undefined
1029
1162
  if (!handler || !handler.renderMarkdown) {
@@ -1045,6 +1178,7 @@ export class MarkdownManager {
1045
1178
  syntheticNode,
1046
1179
  {
1047
1180
  renderChildren: () => placeholder,
1181
+ renderChild: () => placeholder,
1048
1182
  indent: (content: string) => content,
1049
1183
  wrapInBlock: (prefix: string, content: string) => prefix + content,
1050
1184
  },
@@ -1062,7 +1196,11 @@ export class MarkdownManager {
1062
1196
  /**
1063
1197
  * Get the closing markdown syntax for a mark type.
1064
1198
  */
1065
- 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
+
1066
1204
  const handlers = this.getHandlersForNodeType(markType)
1067
1205
  const handler = handlers.length > 0 ? handlers[0] : undefined
1068
1206
  if (!handler || !handler.renderMarkdown) {
@@ -1083,6 +1221,7 @@ export class MarkdownManager {
1083
1221
  syntheticNode,
1084
1222
  {
1085
1223
  renderChildren: () => placeholder,
1224
+ renderChild: () => placeholder,
1086
1225
  indent: (content: string) => content,
1087
1226
  wrapInBlock: (prefix: string, content: string) => prefix + content,
1088
1227
  },
@@ -1098,6 +1237,17 @@ export class MarkdownManager {
1098
1237
  }
1099
1238
  }
1100
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
+
1101
1251
  /**
1102
1252
  * Check if two mark sets are equal.
1103
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