From ec80f95b78a58a9baed627479e13c7c4fac09d93 Mon Sep 17 00:00:00 2001 From: Dennis Hackethal Date: Mon, 22 Sep 2025 23:14:58 -0500 Subject: [PATCH 1/4] Add option to ignore attributes --- src/config.test.ts | 3 ++ src/config.ts | 9 +++++ src/diff.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++ src/diff.ts | 10 +++--- src/util.test.ts | 10 ++++++ src/util.ts | 3 +- 6 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 6c0e2f1..ca0e842 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -141,6 +141,7 @@ describe('simple options', () => { expect(config.modifiedClass).toBe('vdd-modified') expect(config.removedClass).toBe('vdd-removed') expect(config.skipModified).toBe(false) + expect(config.ignoreAttributes).toBe(false) }) test('override', () => { const customDiffText = ( @@ -153,11 +154,13 @@ describe('simple options', () => { modifiedClass: 'MODIFIED', removedClass: 'REMOVED', skipModified: true, + ignoreAttributes: true, }) expect(config.addedClass).toBe('ADDED') expect(config.diffText).toBe(customDiffText) expect(config.modifiedClass).toBe('MODIFIED') expect(config.removedClass).toBe('REMOVED') expect(config.skipModified).toBe(true) + expect(config.ignoreAttributes).toBe(true) }) }) diff --git a/src/config.ts b/src/config.ts index 98db085..0b5ea23 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,12 @@ export interface Options { * Default is `false`. */ skipModified?: boolean + /** + * If `true`, differences in attribute names and values are ignored when comparing nodes: + * the first node's attributes are discarded in favor of the second node's attributes. + * Default is `false`. + */ + ignoreAttributes?: boolean /** * Indicates if the child nodes of the specified `node` should be ignored. * It is useful for ignoring child nodes of an element representing some embedded content, @@ -62,6 +68,7 @@ export interface Config extends Options, DomIteratorOptions { readonly modifiedClass: string readonly removedClass: string readonly skipModified: boolean + readonly ignoreAttributes: boolean readonly skipChildren: NodePredicate readonly skipSelf: NodePredicate readonly diffText: DiffTextType @@ -105,6 +112,7 @@ export function optionsToConfig({ modifiedClass = 'vdd-modified', removedClass = 'vdd-removed', skipModified = false, + ignoreAttributes = false, skipChildren, skipSelf, diffText = diffTextDefault, @@ -115,6 +123,7 @@ export function optionsToConfig({ modifiedClass, removedClass, skipModified, + ignoreAttributes, skipChildren(node: Node): boolean { if ( !isElement(node) && diff --git a/src/diff.test.ts b/src/diff.test.ts index 1610cbf..ab65bdf 100644 --- a/src/diff.test.ts +++ b/src/diff.test.ts @@ -155,6 +155,23 @@ test.each<[string, Node, Node, string, Options | undefined]>([ '', undefined, ], + [ + 'different images with ignored attributes', + (() => { + const img = document.createElement('IMG') + img.setAttribute('src', 'image.png') + return img + })(), + (() => { + const img = document.createElement('IMG') + img.setAttribute('src', 'image.jpg') + return img + })(), + '', + { + ignoreAttributes: true + }, + ], [ 'complex identical content', htmlToFragment( @@ -359,6 +376,15 @@ test.each<[string, Node, Node, string, Options | undefined]>([ '
', undefined, ], + [ + 'differing image src being ignored', + htmlToFragment('
'), + htmlToFragment('
'), + '
', + { + ignoreAttributes: true + }, + ], [ 'differing paragraph attribute - the same text diff', htmlToFragment('

test

'), @@ -366,6 +392,15 @@ test.each<[string, Node, Node, string, Options | undefined]>([ '

test

', undefined, ], + [ + 'differing paragraph attribute being ignored - the same text diff', + htmlToFragment('

test

'), + htmlToFragment('

test

'), + '

test

', + { + ignoreAttributes: true + }, + ], [ 'differing paragraph attribute - different text diff', htmlToFragment('

test

'), @@ -373,6 +408,15 @@ test.each<[string, Node, Node, string, Options | undefined]>([ '

testhello

', undefined, ], + [ + 'differing paragraph attribute being ignored - different text diff', + htmlToFragment('

test

'), + htmlToFragment('

hello

'), + '

testhello

', + { + ignoreAttributes: true + }, + ], [ 'multiple spaces between words', htmlToFragment('prefix suffix'), @@ -918,6 +962,45 @@ test.each<[string, Node, Node, string, Options | undefined]>([ '
111122223333
first columnsecond columnlast column
', undefined, ], + [ + 'ignore different attribute names', + htmlToFragment( + 'foo', + ), + htmlToFragment( + 'foo', + ), + 'foo', + { + ignoreAttributes: true, + }, + ], + [ + 'ignore different attribute values', + htmlToFragment( + 'foo', + ), + htmlToFragment( + 'foo', + ), + 'foo', + { + ignoreAttributes: true, + }, + ], + [ + 'real-life case for ignoring attributes in markdown-generated headings', + htmlToFragment( + '

heading

', + ), + htmlToFragment( + '

heading 2

', + ), + '

heading 2

', + { + ignoreAttributes: true, + }, + ], ])( '%s', ( diff --git a/src/diff.ts b/src/diff.ts index 7b4a0bb..e3a27b6 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -58,6 +58,7 @@ export function visualDomDiff( removedClass, skipSelf, skipChildren, + ignoreAttributes, } = config const notSkipSelf = (node: Node): boolean => !skipSelf(node) const getDepth = (node: Node, rootNode: Node): number => @@ -158,7 +159,7 @@ export function visualDomDiff( } } - function appendCommonChild(node: Node): void { + function appendCommonChild(node: Node, ignoreAttributes?: boolean): void { /* istanbul ignore if */ if (oldOutputNode !== newOutputNode || addedNode || removedNode) { return never() @@ -174,14 +175,14 @@ export function visualDomDiff( modifiedNodes.add(node) } else { for (let i = 0; i < length; ++i) { - if (!areNodesEqual(oldFormatting[i], newFormatting[i])) { + if (!areNodesEqual(oldFormatting[i], newFormatting[i], false, ignoreAttributes)) { modifiedNodes.add(node) break } } } } else { - if (!areNodesEqual(oldNode, newNode)) { + if (!areNodesEqual(oldNode, newNode, false, ignoreAttributes)) { modifiedNodes.add(node) } @@ -352,12 +353,13 @@ export function visualDomDiff( nodeNameOverride(newNode.nodeName) && !skipChildren(oldNode) && !skipChildren(newNode)) || - areNodesEqual(oldNode, newNode)) + areNodesEqual(oldNode, newNode, false, ignoreAttributes)) ) { appendCommonChild( isText(newNode) ? document.createTextNode(text) : newNode.cloneNode(false), + ignoreAttributes, ) } else { appendOldChild( diff --git a/src/util.test.ts b/src/util.test.ts index a626497..0c170b8 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -243,6 +243,16 @@ describe.each<[string, (() => string[]) | undefined]>([ false, ) }) + test('elements with different attribute names being ignored', () => { + expect(areNodesEqual(span, differentAttributeNamesSpan, true, true)).toBe( + true, + ) + }) + test('elements with different attribute values being ignored', () => { + expect(areNodesEqual(span, differentAttributeValuesSpan, true, true)).toBe( + true, + ) + }) test('elements with different childNodes', () => { expect(areNodesEqual(span, differentChildNodesSpan)).toBe(true) }) diff --git a/src/util.ts b/src/util.ts index 9100500..5862c2f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -84,6 +84,7 @@ export function areNodesEqual( node1: Node, node2: Node, deep: boolean = false, + ignoreAttributes = false, ): boolean { if (node1 === node2) { return true @@ -100,7 +101,7 @@ export function areNodesEqual( if (node1.data !== (node2 as typeof node1).data) { return false } - } else if (isElement(node1)) { + } else if (isElement(node1) && !ignoreAttributes) { const attributeNames1 = getAttributeNames(node1).sort() const attributeNames2 = getAttributeNames(node2 as typeof node1).sort() From 76f88b256e96f585f6f85a935c40f5491c7725e1 Mon Sep 17 00:00:00 2001 From: Dennis Hackethal Date: Tue, 23 Sep 2025 12:21:18 -0500 Subject: [PATCH 2/4] Add new ignoreAttributes option to Readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1eb355e..e3a5809 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Changes to attributes of structural elements are treated as modifications (`vdd- - `modifiedClass: string = 'vdd-modified'` The class used for annotating content modifications. - `removedClass: string = 'vdd-removed'` The class used for annotating content removals. - `skipModified: boolean = false` If `true`, then formatting changes are NOT wrapped in `` and modified structural elements are NOT annotated with the `vdd-modified` class. +- `ignoreAttributes: boolean = false` If `true`, then attribute names and values are ignored when comparing nodes. - `skipChildren: (node: Node): boolean | undefined` Indicates if the child nodes of the specified `node` should be ignored. It is useful for ignoring child nodes of an element representing some embedded content, which should not be compared. Return `undefined` for the default behaviour. - `skipSelf: (node: Node): boolean | undefined` Indicates if the specified `node` should be ignored. Even if the `node` is ignored, its child nodes will still be processed, unless `skipChildNodes` says they should also be ignored. Ignored elements whose child nodes are processed are treated as formatting elements. Return `undefined` for the default behaviour. - `diffText: (oldText: string, newText: string): Diff[]` A function to use for diffing serialized representations of DOM nodes, where each DOM element is represented by a single character from the Private Use Area of the Basic Multilingual Unicode Plane. The default implementation is case sensitive and inteligently merges related changes to make the result more user friendly. See the source code for more details, especially if you want to implement a custom `diffText` function. From ab5bc03ae8678c588bb5965048dad54927235e0d Mon Sep 17 00:00:00 2001 From: Dennis Hackethal Date: Tue, 23 Sep 2025 12:51:48 -0500 Subject: [PATCH 3/4] Fix pretty and lint --- src/config.test.ts | 2 +- src/config.ts | 2 +- src/diff.test.ts | 32 ++++++++++---------------------- src/diff.ts | 12 +++++++++--- src/util.test.ts | 22 ++++++++++++++++------ 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index ca0e842..1acd270 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -151,10 +151,10 @@ describe('simple options', () => { const config = optionsToConfig({ addedClass: 'ADDED', diffText: customDiffText, + ignoreAttributes: true, modifiedClass: 'MODIFIED', removedClass: 'REMOVED', skipModified: true, - ignoreAttributes: true, }) expect(config.addedClass).toBe('ADDED') expect(config.diffText).toBe(customDiffText) diff --git a/src/config.ts b/src/config.ts index 0b5ea23..2098c8a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -120,10 +120,10 @@ export function optionsToConfig({ return { addedClass, diffText, + ignoreAttributes, modifiedClass, removedClass, skipModified, - ignoreAttributes, skipChildren(node: Node): boolean { if ( !isElement(node) && diff --git a/src/diff.test.ts b/src/diff.test.ts index ab65bdf..887df44 100644 --- a/src/diff.test.ts +++ b/src/diff.test.ts @@ -169,7 +169,7 @@ test.each<[string, Node, Node, string, Options | undefined]>([ })(), '', { - ignoreAttributes: true + ignoreAttributes: true, }, ], [ @@ -382,7 +382,7 @@ test.each<[string, Node, Node, string, Options | undefined]>([ htmlToFragment('
'), '
', { - ignoreAttributes: true + ignoreAttributes: true, }, ], [ @@ -398,7 +398,7 @@ test.each<[string, Node, Node, string, Options | undefined]>([ htmlToFragment('

test

'), '

test

', { - ignoreAttributes: true + ignoreAttributes: true, }, ], [ @@ -414,7 +414,7 @@ test.each<[string, Node, Node, string, Options | undefined]>([ htmlToFragment('

hello

'), '

testhello

', { - ignoreAttributes: true + ignoreAttributes: true, }, ], [ @@ -964,12 +964,8 @@ test.each<[string, Node, Node, string, Options | undefined]>([ ], [ 'ignore different attribute names', - htmlToFragment( - 'foo', - ), - htmlToFragment( - 'foo', - ), + htmlToFragment('foo'), + htmlToFragment('foo'), 'foo', { ignoreAttributes: true, @@ -977,12 +973,8 @@ test.each<[string, Node, Node, string, Options | undefined]>([ ], [ 'ignore different attribute values', - htmlToFragment( - 'foo', - ), - htmlToFragment( - 'foo', - ), + htmlToFragment('foo'), + htmlToFragment('foo'), 'foo', { ignoreAttributes: true, @@ -990,12 +982,8 @@ test.each<[string, Node, Node, string, Options | undefined]>([ ], [ 'real-life case for ignoring attributes in markdown-generated headings', - htmlToFragment( - '

heading

', - ), - htmlToFragment( - '

heading 2

', - ), + htmlToFragment('

heading

'), + htmlToFragment('

heading 2

'), '

heading 2

', { ignoreAttributes: true, diff --git a/src/diff.ts b/src/diff.ts index e3a27b6..ea80869 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -159,7 +159,7 @@ export function visualDomDiff( } } - function appendCommonChild(node: Node, ignoreAttributes?: boolean): void { + function appendCommonChild(node: Node): void { /* istanbul ignore if */ if (oldOutputNode !== newOutputNode || addedNode || removedNode) { return never() @@ -175,7 +175,14 @@ export function visualDomDiff( modifiedNodes.add(node) } else { for (let i = 0; i < length; ++i) { - if (!areNodesEqual(oldFormatting[i], newFormatting[i], false, ignoreAttributes)) { + if ( + !areNodesEqual( + oldFormatting[i], + newFormatting[i], + false, + ignoreAttributes, + ) + ) { modifiedNodes.add(node) break } @@ -359,7 +366,6 @@ export function visualDomDiff( isText(newNode) ? document.createTextNode(text) : newNode.cloneNode(false), - ignoreAttributes, ) } else { appendOldChild( diff --git a/src/util.test.ts b/src/util.test.ts index 0c170b8..348a410 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -244,14 +244,24 @@ describe.each<[string, (() => string[]) | undefined]>([ ) }) test('elements with different attribute names being ignored', () => { - expect(areNodesEqual(span, differentAttributeNamesSpan, true, true)).toBe( - true, - ) + expect( + areNodesEqual( + span, + differentAttributeNamesSpan, + true, + true, + ), + ).toBe(true) }) test('elements with different attribute values being ignored', () => { - expect(areNodesEqual(span, differentAttributeValuesSpan, true, true)).toBe( - true, - ) + expect( + areNodesEqual( + span, + differentAttributeValuesSpan, + true, + true, + ), + ).toBe(true) }) test('elements with different childNodes', () => { expect(areNodesEqual(span, differentChildNodesSpan)).toBe(true) From d7a962addf94b301582bb4576ddef8918a4b17ab Mon Sep 17 00:00:00 2001 From: Dennis Hackethal Date: Tue, 23 Sep 2025 12:56:08 -0500 Subject: [PATCH 4/4] Improve placing of ignoreAttributes throughout code --- README.md | 2 +- src/config.test.ts | 2 +- src/config.ts | 16 ++++++++-------- src/diff.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e3a5809..31ce381 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ Changes to attributes of structural elements are treated as modifications (`vdd- #### Options - `addedClass: string = 'vdd-added'` The class used for annotating content additions. +- `ignoreAttributes: boolean = false` If `true`, then attribute names and values are ignored when comparing nodes. - `modifiedClass: string = 'vdd-modified'` The class used for annotating content modifications. - `removedClass: string = 'vdd-removed'` The class used for annotating content removals. - `skipModified: boolean = false` If `true`, then formatting changes are NOT wrapped in `` and modified structural elements are NOT annotated with the `vdd-modified` class. -- `ignoreAttributes: boolean = false` If `true`, then attribute names and values are ignored when comparing nodes. - `skipChildren: (node: Node): boolean | undefined` Indicates if the child nodes of the specified `node` should be ignored. It is useful for ignoring child nodes of an element representing some embedded content, which should not be compared. Return `undefined` for the default behaviour. - `skipSelf: (node: Node): boolean | undefined` Indicates if the specified `node` should be ignored. Even if the `node` is ignored, its child nodes will still be processed, unless `skipChildNodes` says they should also be ignored. Ignored elements whose child nodes are processed are treated as formatting elements. Return `undefined` for the default behaviour. - `diffText: (oldText: string, newText: string): Diff[]` A function to use for diffing serialized representations of DOM nodes, where each DOM element is represented by a single character from the Private Use Area of the Basic Multilingual Unicode Plane. The default implementation is case sensitive and inteligently merges related changes to make the result more user friendly. See the source code for more details, especially if you want to implement a custom `diffText` function. diff --git a/src/config.test.ts b/src/config.test.ts index 1acd270..237d83e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -158,9 +158,9 @@ describe('simple options', () => { }) expect(config.addedClass).toBe('ADDED') expect(config.diffText).toBe(customDiffText) + expect(config.ignoreAttributes).toBe(true) expect(config.modifiedClass).toBe('MODIFIED') expect(config.removedClass).toBe('REMOVED') expect(config.skipModified).toBe(true) - expect(config.ignoreAttributes).toBe(true) }) }) diff --git a/src/config.ts b/src/config.ts index 2098c8a..5e7a0d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,12 @@ export interface Options { * Default is `'vdd-modified'`. */ modifiedClass?: string + /** + * If `true`, differences in attribute names and values are ignored when comparing nodes: + * the first node's attributes are discarded in favor of the second node's attributes. + * Default is `false`. + */ + ignoreAttributes?: boolean /** * The class name to use to mark up removed content. * Default is `'vdd-removed'`. @@ -34,12 +40,6 @@ export interface Options { * Default is `false`. */ skipModified?: boolean - /** - * If `true`, differences in attribute names and values are ignored when comparing nodes: - * the first node's attributes are discarded in favor of the second node's attributes. - * Default is `false`. - */ - ignoreAttributes?: boolean /** * Indicates if the child nodes of the specified `node` should be ignored. * It is useful for ignoring child nodes of an element representing some embedded content, @@ -65,10 +65,10 @@ export interface Options { export interface Config extends Options, DomIteratorOptions { readonly addedClass: string + readonly ignoreAttributes: boolean readonly modifiedClass: string readonly removedClass: string readonly skipModified: boolean - readonly ignoreAttributes: boolean readonly skipChildren: NodePredicate readonly skipSelf: NodePredicate readonly diffText: DiffTextType @@ -111,8 +111,8 @@ export function optionsToConfig({ addedClass = 'vdd-added', modifiedClass = 'vdd-modified', removedClass = 'vdd-removed', - skipModified = false, ignoreAttributes = false, + skipModified = false, skipChildren, skipSelf, diffText = diffTextDefault, diff --git a/src/diff.ts b/src/diff.ts index ea80869..fe25e28 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -54,11 +54,11 @@ export function visualDomDiff( const { addedClass, diffText, + ignoreAttributes, modifiedClass, removedClass, skipSelf, skipChildren, - ignoreAttributes, } = config const notSkipSelf = (node: Node): boolean => !skipSelf(node) const getDepth = (node: Node, rootNode: Node): number =>