mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-07-10 04:21:21 +02:00
core: make html equality check more robust
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user