core: make html equality check more robust

This commit is contained in:
Abdullah Atta
2026-06-11 22:06:56 +05:00
parent edcabd9f4b
commit 3fa3999ed7
2 changed files with 171 additions and 22 deletions

View File

@@ -26,28 +26,44 @@ const equalPairs = [
`<div>hello \n\n\n\n\n</div>\n\n\n\n\n\n`,
`<div>hello</div>`
],
["ignore html structure", `<p><b>hello</b>world</p>`, "<p>helloworld</p>"],
[
"ignore attributes",
`<p id="ignored"><b id="ignored">hello</b>world</p>`,
"<p>helloworld</p>"
"ignore non-semantic attributes",
`<p id="ignored" class="one" data-extra="x">hello world</p>`,
`<p>hello world</p>`
],
[
"ignore empty tags",
"<div>helloworld</div><p></p>",
"<div>helloworld</div>"
"same formatting preserved",
`<p><strong>hello</strong> world</p>`,
`<p><strong>hello</strong> world</p>`
],
["ignore br", "<p>hello<br/>world</p><p><br/><br/></p>", "<p>helloworld</p>"],
[
"same list structure",
`<ul><li><p>item one</p></li><li><p>item two</p></li></ul>`,
`<ul><li><p>item one</p></li><li><p>item two</p></li></ul>`
],
[
"ignore trailing empty paragraph",
`<div>helloworld</div><p></p>`,
`<div>helloworld</div>`
],
[
"ignore tiptap empty paragraph placeholder",
`<div>helloworld</div><p><br/></p>`,
`<div>helloworld</div>`
],
["ignore deeply nested empty tags", `<ul><li></li></ul>`, ``],
[
"image with same src",
`<img src="./img.jpeg" />`,
`<img id="hello" class="diff" src="./img.jpeg" />`
],
[
"link with same href",
`<a href="google.com" />`,
`<a id="hello" class="diff" href="google.com" />`
]
"link with same href and text",
`<a href="google.com">click here</a>`,
`<a id="hello" class="diff" href="google.com">click here</a>`
],
["case insensitive tags", `<P>hello</P>`, `<p>hello</p>`],
["whitespace inside semantic tags", `<p>hello \n\n</p>`, `<p>hello</p>`]
];
describe("pairs should be equal", () => {
@@ -72,7 +88,57 @@ const inequalPairs = [
`<a href="brave.com" />`,
`<a id="hello" class="diff" href="google.com" />`
],
["non-string", {}, {}]
["non-string", {}, {}],
// formatting differences
["bold vs no bold", `<p><b>hello</b>world</p>`, `<p>helloworld</p>`],
["bold vs italic", `<p><strong>hello</strong></p>`, `<p><em>hello</em></p>`],
["strikethrough vs underline", `<p><s>hello</s></p>`, `<p><u>hello</u></p>`],
["code vs plain text", `<p><code>hello</code></p>`, `<p>hello</p>`],
// structural differences
["heading vs paragraph", `<h1>Title</h1>`, `<p>Title</p>`],
["different heading level", `<h1>Title</h1>`, `<h2>Title</h2>`],
[
"ordered vs unordered list",
`<ul><li>item</li></ul>`,
`<ol><li>item</li></ol>`
],
[
"list item reordering",
`<ol><li><p>first</p></li><li><p>second</p></li></ol>`,
`<ol><li><p>second</p></li><li><p>first</p></li></ol>`
],
[
"blockquote vs paragraph",
`<blockquote><p>quoted</p></blockquote>`,
`<p>quoted</p>`
],
[
"br creates structural difference via text splitting",
`<p>hello<br/>world</p>`,
`<p>helloworld</p>`
],
[
"hr is a meaningful structural element",
`<p>above</p><hr/><p>below</p>`,
`<p>above</p><p>below</p>`
],
// separator prevents text-node concatenation collisions
[
"adjacent paragraphs do not collide with single paragraph",
`<p>foo</p><p>bar</p>`,
`<p>foobar</p>`
],
[
"href value does not collide with adjacent text",
`<a href="abc">def</a>ghi`,
`<p>abcdef</p><a href="">ghi</a>`
],
// link text difference
[
"link text changed with same href",
`<a href="google.com">click here</a>`,
`<a href="google.com">go there</a>`
]
];
describe("pairs should not be equal", () => {

View File

@@ -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. <hr> is meaningful).
// <br> 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. ["<p>", "</p>"] → []. Iterates until stable so that nested
* empty tags collapse fully: <ul><li></li></ul> → [].
*/
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] === `</${tokens[i].slice(1)}`
) {
i += 2; // skip the empty pair
} else {
result.push(tokens[i]);
i++;
}
}
tokens = result;
}
return tokens;
}
function toDiffable(html: string) {
let text = "";
const tokens: string[] = [];
const parser = new Parser(
{
ontext: (data) => (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(`</${name}>`);
}
},
{
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");
}