diff --git a/packages/core/src/utils/__tests__/html-diff.test.js b/packages/core/src/utils/__tests__/html-diff.test.js index 9e3c39c52..49582bad5 100644 --- a/packages/core/src/utils/__tests__/html-diff.test.js +++ b/packages/core/src/utils/__tests__/html-diff.test.js @@ -26,28 +26,44 @@ const equalPairs = [ `
hello \n\n\n\n\n
\n\n\n\n\n\n`, `
hello
` ], - ["ignore html structure", `

helloworld

`, "

helloworld

"], [ - "ignore attributes", - `

helloworld

`, - "

helloworld

" + "ignore non-semantic attributes", + `

hello world

`, + `

hello world

` ], [ - "ignore empty tags", - "
helloworld

", - "
helloworld
" + "same formatting preserved", + `

hello world

`, + `

hello world

` ], - ["ignore br", "

hello
world



", "

helloworld

"], + [ + "same list structure", + ``, + `` + ], + [ + "ignore trailing empty paragraph", + `
helloworld

`, + `
helloworld
` + ], + [ + "ignore tiptap empty paragraph placeholder", + `
helloworld


`, + `
helloworld
` + ], + ["ignore deeply nested empty tags", ``, ``], [ "image with same src", ``, `` ], [ - "link with same href", - ``, - `` - ] + "link with same href and text", + `click here`, + `click here` + ], + ["case insensitive tags", `

hello

`, `

hello

`], + ["whitespace inside semantic tags", `

hello \n\n

`, `

hello

`] ]; describe("pairs should be equal", () => { @@ -72,7 +88,57 @@ const inequalPairs = [ ``, `` ], - ["non-string", {}, {}] + ["non-string", {}, {}], + // formatting differences + ["bold vs no bold", `

helloworld

`, `

helloworld

`], + ["bold vs italic", `

hello

`, `

hello

`], + ["strikethrough vs underline", `

hello

`, `

hello

`], + ["code vs plain text", `

hello

`, `

hello

`], + // structural differences + ["heading vs paragraph", `

Title

`, `

Title

`], + ["different heading level", `

Title

`, `

Title

`], + [ + "ordered vs unordered list", + ``, + `
  1. item
` + ], + [ + "list item reordering", + `
  1. first

  2. second

`, + `
  1. second

  2. first

` + ], + [ + "blockquote vs paragraph", + `

quoted

`, + `

quoted

` + ], + [ + "br creates structural difference via text splitting", + `

hello
world

`, + `

helloworld

` + ], + [ + "hr is a meaningful structural element", + `

above


below

`, + `

above

below

` + ], + // separator prevents text-node concatenation collisions + [ + "adjacent paragraphs do not collide with single paragraph", + `

foo

bar

`, + `

foobar

` + ], + [ + "href value does not collide with adjacent text", + `
defghi`, + `

abcdef

ghi` + ], + // link text difference + [ + "link text changed with same href", + `click here`, + `go there` + ] ]; describe("pairs should not be equal", () => { diff --git a/packages/core/src/utils/html-diff.ts b/packages/core/src/utils/html-diff.ts index 3d03771c3..d706192fb 100644 --- a/packages/core/src/utils/html-diff.ts +++ b/packages/core/src/utils/html-diff.ts @@ -21,30 +21,113 @@ import { Parser } from "htmlparser2"; const ALLOWED_ATTRIBUTES = ["href", "src", "data-hash"]; +// Tags whose presence, absence, or nesting order constitutes a semantic +// difference — tracked as tokens so structural/formatting changes are detected. +const SEMANTIC_TAGS = new Set([ + // block structure + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "pre", + "ul", + "ol", + "li", + "table", + "tr", + "td", + "th", + "thead", + "tbody", + "hr", + // inline formatting + "strong", + "b", + "em", + "i", + "u", + "s", + "del", + "mark", + "sup", + "sub", + "code", + "a" +]); + +// Void elements cannot have children. We emit only an open token for them so +// empty-pair removal never accidentally strips them (e.g.
is meaningful). +//
is intentionally absent from SEMANTIC_TAGS altogether: it is a void +// element used as an empty-paragraph placeholder in TipTap, and meaningful +// line breaks are already detectable via separate text node tokens. +const VOID_ELEMENTS = new Set(["hr"]); + export function isHTMLEqual(one: unknown, two: unknown) { if (typeof one !== "string" || typeof two !== "string") return false; return toDiffable(one) === toDiffable(two); } +/** + * Repeatedly removes adjacent open/close pairs that have no content between + * them, e.g. ["

", "

"] → []. Iterates until stable so that nested + * empty tags collapse fully: → []. + */ +function removeEmptyTagPairs(tokens: string[]): string[] { + let prevLength = -1; + while (tokens.length !== prevLength) { + prevLength = tokens.length; + const result: string[] = []; + let i = 0; + while (i < tokens.length) { + if ( + i + 1 < tokens.length && + tokens[i][0] === "<" && + tokens[i][1] !== "/" && + tokens[i + 1] === ` (text += data.trim()), - onopentag: (_name, attr) => { + ontext: (data) => { + const trimmed = data.trim(); + if (trimmed) tokens.push(trimmed); + }, + onopentag: (name, attr) => { + if (SEMANTIC_TAGS.has(name)) tokens.push(`<${name}>`); for (const key of ALLOWED_ATTRIBUTES) { const value = attr[key]; if (!value) continue; - text += value.trim(); + tokens.push(value.trim()); } + }, + onclosetag: (name) => { + // Void elements never have children so their close token would always + // form an empty pair and be stripped — skip it entirely. + if (VOID_ELEMENTS.has(name)) return; + if (SEMANTIC_TAGS.has(name)) tokens.push(``); } }, - { - lowerCaseTags: false - // parseAttributes: true - } + { lowerCaseTags: true } ); parser.end(html); - return text; + // \x00 is used as separator — it cannot appear in HTML content, so + // "foo" + "bar" in separate nodes can never collide with "foobar" in one. + return removeEmptyTagPairs(tokens).join("\x00"); }