diff --git a/README.md b/README.md index 1eb355e..31ce381 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ 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. diff --git a/src/config.test.ts b/src/config.test.ts index 6c0e2f1..237d83e 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 = ( @@ -150,12 +151,14 @@ describe('simple options', () => { const config = optionsToConfig({ addedClass: 'ADDED', diffText: customDiffText, + ignoreAttributes: true, modifiedClass: 'MODIFIED', removedClass: 'REMOVED', skipModified: true, }) 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) diff --git a/src/config.ts b/src/config.ts index 98db085..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'`. @@ -59,6 +65,7 @@ export interface Options { export interface Config extends Options, DomIteratorOptions { readonly addedClass: string + readonly ignoreAttributes: boolean readonly modifiedClass: string readonly removedClass: string readonly skipModified: boolean @@ -104,6 +111,7 @@ export function optionsToConfig({ addedClass = 'vdd-added', modifiedClass = 'vdd-modified', removedClass = 'vdd-removed', + ignoreAttributes = false, skipModified = false, skipChildren, skipSelf, @@ -112,6 +120,7 @@ export function optionsToConfig({ return { addedClass, diffText, + ignoreAttributes, modifiedClass, removedClass, skipModified, diff --git a/src/diff.test.ts b/src/diff.test.ts index 1610cbf..887df44 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,33 @@ 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..fe25e28 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -54,6 +54,7 @@ export function visualDomDiff( const { addedClass, diffText, + ignoreAttributes, modifiedClass, removedClass, skipSelf, @@ -174,14 +175,21 @@ 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,7 +360,7 @@ export function visualDomDiff( nodeNameOverride(newNode.nodeName) && !skipChildren(oldNode) && !skipChildren(newNode)) || - areNodesEqual(oldNode, newNode)) + areNodesEqual(oldNode, newNode, false, ignoreAttributes)) ) { appendCommonChild( isText(newNode) diff --git a/src/util.test.ts b/src/util.test.ts index a626497..348a410 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -243,6 +243,26 @@ 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()