Skip to content

Commit 9cf8db0

Browse files
authored
fix(markdown): add backslash-escape handling in parser and serializer (#7898)
* fix(markdown): improve backslash-escape handling in parser and serializer * Add marksEqual util and use it to compare marks Replace JSON.stringify-based mark comparison in MarkdownManager with marksEqual. Add unit tests for marksEqual covering key order, missing attrs, length and type differences * Add attrsEqual and marksEqual utilities to core Export attrsEqual and marksEqual from @tiptap/core utilities. Add unit tests for both, remove duplicated implementations in the markdown package, and add a changeset
1 parent 26e6f0f commit 9cf8db0

10 files changed

Lines changed: 464 additions & 30 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiptap/core': patch
3+
---
4+
5+
Add `attrsEqual` and `marksEqual` utility functions to `@tiptap/core`. `attrsEqual` compares two attribute objects for equality regardless of key ordering. `marksEqual` compares two arrays of mark objects by type and attributes using `attrsEqual`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiptap/markdown': patch
3+
---
4+
5+
Fix backslash-escape handling in the Markdown parser and serializer. Parsing a backslash-escaped markdown character (e.g. `\*`, `\_`, `\\`) now correctly produces a literal text node, instead of silently dropping the character. On serialization, characters that have special meaning in markdown inline syntax (`*`, `_`, `` ` ``, `[`, `]`, `\`, `~`) are now backslash-escaped in non-code text nodes to prevent them from being misinterpreted as formatting delimiters when the output is parsed again.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { attrsEqual } from '@tiptap/core'
2+
import { describe, expect, it } from 'vitest'
3+
4+
describe('attrsEqual', () => {
5+
it('returns true for identical objects with same key order', () => {
6+
expect(attrsEqual({ level: 1, color: 'red' }, { level: 1, color: 'red' })).toBe(true)
7+
})
8+
9+
it('returns true for identical objects with different key order', () => {
10+
expect(attrsEqual({ level: 1, color: 'red' }, { color: 'red', level: 1 })).toBe(true)
11+
})
12+
13+
it('returns true for empty objects', () => {
14+
expect(attrsEqual({}, {})).toBe(true)
15+
})
16+
17+
it('returns true when both are null', () => {
18+
expect(attrsEqual(null, null)).toBe(true)
19+
})
20+
21+
it('returns true when both are undefined', () => {
22+
expect(attrsEqual(undefined, undefined)).toBe(true)
23+
})
24+
25+
it('returns true for same reference', () => {
26+
const obj = { level: 1 }
27+
expect(attrsEqual(obj, obj)).toBe(true)
28+
})
29+
30+
it('returns false when one is null and the other is an object', () => {
31+
expect(attrsEqual(null, { level: 1 })).toBe(false)
32+
})
33+
34+
it('returns false when one is undefined and the other is an object', () => {
35+
expect(attrsEqual(undefined, { level: 1 })).toBe(false)
36+
})
37+
38+
it('returns false for objects with different keys', () => {
39+
expect(attrsEqual({ level: 1 }, { level: 1, color: 'red' })).toBe(false)
40+
})
41+
42+
it('returns false for objects with same keys but different values', () => {
43+
expect(attrsEqual({ level: 1 }, { level: 2 })).toBe(false)
44+
})
45+
46+
it('returns false when a key is present in both but one value is undefined', () => {
47+
expect(attrsEqual({ level: undefined }, { level: 1 })).toBe(false)
48+
})
49+
50+
it('distinguishes { foo: undefined } from { bar: undefined }', () => {
51+
expect(attrsEqual({ foo: undefined }, { bar: undefined })).toBe(false)
52+
})
53+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { marksEqual } from '@tiptap/core'
2+
import { describe, expect, it } from 'vitest'
3+
4+
describe('marksEqual', () => {
5+
it('returns true for identical marks with same attrs key order', () => {
6+
const a = [{ type: 'bold', attrs: { level: 1, color: 'red' } }]
7+
const b = [{ type: 'bold', attrs: { level: 1, color: 'red' } }]
8+
expect(marksEqual(a, b)).toBe(true)
9+
})
10+
11+
it('returns true for identical marks with different attrs key order', () => {
12+
const a = [{ type: 'bold', attrs: { level: 1, color: 'red' } }]
13+
const b = [{ type: 'bold', attrs: { color: 'red', level: 1 } }]
14+
expect(marksEqual(a, b)).toBe(true)
15+
})
16+
17+
it('returns true for identical marks without attrs', () => {
18+
const a = [{ type: 'italic' }, { type: 'bold' }]
19+
const b = [{ type: 'italic' }, { type: 'bold' }]
20+
expect(marksEqual(a, b)).toBe(true)
21+
})
22+
23+
it('returns false for marks with different types', () => {
24+
const a = [{ type: 'bold', attrs: { level: 1 } }]
25+
const b = [{ type: 'italic', attrs: { level: 1 } }]
26+
expect(marksEqual(a, b)).toBe(false)
27+
})
28+
29+
it('returns false for marks with different attrs', () => {
30+
const a = [{ type: 'bold', attrs: { level: 1 } }]
31+
const b = [{ type: 'bold', attrs: { level: 2 } }]
32+
expect(marksEqual(a, b)).toBe(false)
33+
})
34+
35+
it('returns false when arrays have different lengths', () => {
36+
const a = [{ type: 'bold' }, { type: 'italic' }]
37+
const b = [{ type: 'bold' }]
38+
expect(marksEqual(a, b)).toBe(false)
39+
})
40+
41+
it('returns false when one has attrs and the other does not', () => {
42+
const a = [{ type: 'bold', attrs: { level: 1 } }]
43+
const b = [{ type: 'bold' }]
44+
expect(marksEqual(a, b)).toBe(false)
45+
})
46+
47+
it('returns true for empty arrays', () => {
48+
expect(marksEqual([], [])).toBe(true)
49+
})
50+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Compare two attribute objects for equality.
3+
* Handles null/undefined and asserts key presence in both objects so that
4+
* `{ foo: undefined }` and `{ bar: undefined }` are not treated as equal.
5+
*/
6+
export function attrsEqual(
7+
a: Record<string, any> | null | undefined,
8+
b: Record<string, any> | null | undefined,
9+
): boolean {
10+
if (a === b) {
11+
return true
12+
}
13+
if (!a || !b) {
14+
return false
15+
}
16+
17+
const keysA = Object.keys(a)
18+
const keysB = Object.keys(b)
19+
20+
if (keysA.length !== keysB.length) {
21+
return false
22+
}
23+
24+
return keysA.every(
25+
key => Object.prototype.hasOwnProperty.call(b, key) && Object.is(a[key], b[key]),
26+
)
27+
}

packages/core/src/utilities/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './attrsEqual.js'
12
export * from './callOrReturn.js'
23
export * from './canInsertNode.js'
34
export * from './createStyleTag.js'
@@ -21,6 +22,7 @@ export * from './isSafari.js'
2122
export * from './isString.js'
2223
export * from './markdown/index.js'
2324
export * as markdown from './markdown/index.js'
25+
export * from './marksEqual.js'
2426
export * from './mergeAttributes.js'
2527
export * from './mergeDeep.js'
2628
export * from './minMax.js'
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { attrsEqual } from './attrsEqual.js'
2+
3+
/**
4+
* Compare two arrays of mark objects for equality.
5+
* Marks are compared by type and attributes (using attrsEqual),
6+
* so key ordering in attrs does not matter.
7+
*/
8+
export function marksEqual(
9+
a: { type: string; attrs?: Record<string, any> }[],
10+
b: { type: string; attrs?: Record<string, any> }[],
11+
): boolean {
12+
if (a.length !== b.length) {
13+
return false
14+
}
15+
16+
return a.every((mark, i) => {
17+
const other = b[i]
18+
return mark.type === other.type && attrsEqual(mark.attrs, other.attrs)
19+
})
20+
}

0 commit comments

Comments
 (0)