@matrixreq/htmldiff 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/js/htmldiff.js +310 -14
- package/package.json +1 -1
- package/test/diff.spec.js +59 -0
- package/test/html_to_tokens.spec.js +73 -0
- package/test/inner_diff.spec.js +333 -0
package/README.md
CHANGED
|
@@ -18,6 +18,34 @@ Quote from the original source of this fork:
|
|
|
18
18
|
- TypeScript support.
|
|
19
19
|
- Better documentation.
|
|
20
20
|
|
|
21
|
+
**@matrixreq/htmldiff** is the Matrix Requirements fork of
|
|
22
|
+
[node-htmldiff](https://github.com/idesis-gmbh/htmldiff.js) and adds the following on top
|
|
23
|
+
of it:
|
|
24
|
+
|
|
25
|
+
- Identity matching: elements with a `data-htmldiff-id` attribute are treated as atomic and
|
|
26
|
+
compared by the attribute value instead of by their content.
|
|
27
|
+
- An opt-in recursive inner diff for identity-matched elements via the
|
|
28
|
+
`data-htmldiff-inner-diff` and `data-htmldiff-inner-diff-atomic-tags` attributes. See
|
|
29
|
+
*Identity matching and recursive inner diff* below for both features.
|
|
30
|
+
- Atomic tags may contain nested same-named children: the atomic token ends only when the
|
|
31
|
+
tag's nesting depth returns to zero, and a stray `>` inside e.g. script content is not
|
|
32
|
+
treated as a tag boundary.
|
|
33
|
+
- Tokenizer robustness: atomic tag names only match complete names (`<abbr>` is not
|
|
34
|
+
mistaken for the atomic tag `a`), and self-closing (`<div/>`) or void (`<img>`, `<br>`,
|
|
35
|
+
...) atomic elements do not swallow the content following them.
|
|
36
|
+
- Quote-aware tag parsing: `>` and `/>` inside quoted attribute values (e.g.
|
|
37
|
+
`title="a > b"`) do not end a tag prematurely, neither when tokenizing nor when
|
|
38
|
+
splitting an element for the recursive inner diff.
|
|
39
|
+
- Adjacent atomic tags are wrapped in their own `<ins>`/`<del>` tags instead of being
|
|
40
|
+
combined into one.
|
|
41
|
+
- Inserted tags are marked with a `data-inserted="true"` attribute.
|
|
42
|
+
- Whitespace handling: repeated whitespace (except newlines) as well as ` `/` `
|
|
43
|
+
compare as equal to regular spaces.
|
|
44
|
+
- Deletions of a closing/opening tag pair (e.g. the `</p><p>` removed when two paragraphs
|
|
45
|
+
are merged) render as a proper `<del>` instead of unbalanced tags.
|
|
46
|
+
- Fixed the `atomicTags` parameter of the diff function: the custom tag list was previously
|
|
47
|
+
ignored due to a broken regular expression.
|
|
48
|
+
|
|
21
49
|
See also *Credits* below.
|
|
22
50
|
|
|
23
51
|
## Description
|
|
@@ -68,6 +96,55 @@ of these three parameters it will be ignored:
|
|
|
68
96
|
`iframe,object,math,svg,script,video,head,style`.
|
|
69
97
|
|
|
70
98
|
|
|
99
|
+
### Identity matching and recursive inner diff
|
|
100
|
+
|
|
101
|
+
Elements can be matched across the two documents by identity instead of content by giving
|
|
102
|
+
them a `data-htmldiff-id` attribute. Such an element is treated as atomic (its children are
|
|
103
|
+
never diffed) and compared solely by the attribute value: two elements with the same id are
|
|
104
|
+
considered equal even if their content differs, in which case the after version is rendered
|
|
105
|
+
as is.
|
|
106
|
+
|
|
107
|
+
To make content changes of such identity-matched elements visible, add the
|
|
108
|
+
`data-htmldiff-inner-diff="true"` attribute (alongside `data-htmldiff-id`). A bare
|
|
109
|
+
attribute or any value other than `false` enables the behavior;
|
|
110
|
+
`data-htmldiff-inner-diff="false"` disables it:
|
|
111
|
+
|
|
112
|
+
```html
|
|
113
|
+
<div class="toc-entry" data-htmldiff-id="sec-1" data-htmldiff-inner-diff="true">
|
|
114
|
+
<a href="#123">1. Section name</a>
|
|
115
|
+
</div>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
When two matched atomic tokens have the same key but different content and the element has
|
|
119
|
+
opted in, the element is rendered once (with the after version's opening and closing tags)
|
|
120
|
+
and its inner HTML is diffed recursively, so e.g. a renamed entry shows up as
|
|
121
|
+
`... <del>Old name</del><ins>New name</ins> ...` inline. Inside the recursive diff the
|
|
122
|
+
default atomic tag list without `a` is used
|
|
123
|
+
(`iframe,object,math,svg,script,video,head,style`): link text is diffed word by word and
|
|
124
|
+
href-only changes do not produce any diff markup, while embedded content like svg stays
|
|
125
|
+
atomic. To use a different atomic tags list inside the recursive diff, set the
|
|
126
|
+
`data-htmldiff-inner-diff-atomic-tags` attribute on the opted-in element:
|
|
127
|
+
|
|
128
|
+
```html
|
|
129
|
+
<div data-htmldiff-id="sec-1" data-htmldiff-inner-diff="true"
|
|
130
|
+
data-htmldiff-inner-diff-atomic-tags="svg,iframe,a">
|
|
131
|
+
...
|
|
132
|
+
</div>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The value replaces the default list and has the same format as the `atomicTags` API
|
|
136
|
+
parameter; an empty value means no tag name is atomic. The attribute is read from the after
|
|
137
|
+
version of the element and is only consulted on elements that opted in via
|
|
138
|
+
`data-htmldiff-inner-diff`.
|
|
139
|
+
|
|
140
|
+
Opted-in elements nested inside other opted-in elements are diffed recursively as well;
|
|
141
|
+
each nesting level requires its own `data-htmldiff-inner-diff` attribute.
|
|
142
|
+
|
|
143
|
+
Limitations:
|
|
144
|
+
|
|
145
|
+
- The recursion depth is capped at 10 levels as a backstop against deep
|
|
146
|
+
nesting. Opted-in elements beyond the cap are rendered as their after version.
|
|
147
|
+
|
|
71
148
|
### Example
|
|
72
149
|
|
|
73
150
|
JavaScript:
|
package/js/htmldiff.js
CHANGED
|
@@ -68,10 +68,62 @@
|
|
|
68
68
|
* Regular expression to check atomic tags.
|
|
69
69
|
* @see function diff.
|
|
70
70
|
*/
|
|
71
|
-
var atomicTagsRegExp;
|
|
72
71
|
// Added head and style (for style tags inside the body)
|
|
73
|
-
|
|
72
|
+
// The tag name must be followed by a delimiter (not a \b word boundary): the tokenizer
|
|
73
|
+
// matches against partially read tags, and a word boundary would match at the end of an
|
|
74
|
+
// incomplete name, e.g. detecting '<abbr>' as the atomic tag 'a' while reading '<a'.
|
|
75
|
+
var defaultAtomicTagsRegExp = new RegExp('^<(iframe|object|math|svg|script|video|head|style|a)[\\s/>]');
|
|
76
|
+
var atomicTagsRegExp = defaultAtomicTagsRegExp;
|
|
74
77
|
const dataHtmlDiffIdRegExp = /^<([a-z\-]+).+data-htmldiff-id=["']?((?:.(?!["']?\s+(?:\S+)=|\s*\/?[>"']))*.)["']?/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Opt-in marker for the recursive inner diff. When two matched atomic tokens (typically
|
|
81
|
+
* matched by data-htmldiff-id) have equal keys but different content, an element carrying
|
|
82
|
+
* this attribute gets its inner HTML diffed recursively instead of being rendered as is.
|
|
83
|
+
* The attribute must appear in the element's opening tag. Captures the attribute value;
|
|
84
|
+
* a bare attribute or any value other than "false" enables the opt-in.
|
|
85
|
+
* @see OPS.equal and renderInnerDiff.
|
|
86
|
+
*/
|
|
87
|
+
const dataHtmlDiffInnerDiffRegExp =
|
|
88
|
+
/^<[^>]*\sdata-htmldiff-inner-diff(?:\s*=\s*["']?([^"'\s/>]*)|(?=[\s/>]))/;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Per-element override for the atomic tags used inside a recursive inner diff. The value
|
|
92
|
+
* is a comma separated tag name list, like the atomicTags parameter of the diff function;
|
|
93
|
+
* an empty value means no tag name is atomic. Without the attribute the default list
|
|
94
|
+
* without 'a' is used, so anchors have their text content diffed word by word while
|
|
95
|
+
* embedded content like svg stays atomic. The attribute is only consulted on elements
|
|
96
|
+
* that opted in via data-htmldiff-inner-diff.
|
|
97
|
+
*/
|
|
98
|
+
const dataHtmlDiffInnerDiffAtomicTagsRegExp =
|
|
99
|
+
/^<[^>]*\sdata-htmldiff-inner-diff-atomic-tags\s*=\s*["']([^"']*)["']/;
|
|
100
|
+
|
|
101
|
+
// Atomic tags used inside a recursive inner diff unless the element overrides them via
|
|
102
|
+
// data-htmldiff-inner-diff-atomic-tags: the default list without 'a'.
|
|
103
|
+
var defaultInnerDiffAtomicTagsRegExp =
|
|
104
|
+
new RegExp('^<(iframe|object|math|svg|script|video|head|style)[\\s/>]');
|
|
105
|
+
|
|
106
|
+
// Matches no tag at all: used when data-htmldiff-inner-diff-atomic-tags is empty.
|
|
107
|
+
const noAtomicTagsRegExp = /^<(?!)/;
|
|
108
|
+
|
|
109
|
+
// The number of currently active recursive inner diffs. Recursion is governed per
|
|
110
|
+
// element (each nesting level requires its own data-htmldiff-inner-diff attribute), so
|
|
111
|
+
// the depth is naturally bounded by the nesting of opted-in elements; the cap is only a
|
|
112
|
+
// backstop against pathologically deep documents.
|
|
113
|
+
var innerDiffDepth = 0;
|
|
114
|
+
const maxInnerDiffDepth = 10;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Builds the atomic tags regular expression from a comma separated tag name list.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} atomicTags Comma separated list of tag names, e.g. 'head,script,style'.
|
|
120
|
+
*
|
|
121
|
+
* @return {RegExp} The regular expression matching the start of those tags.
|
|
122
|
+
*/
|
|
123
|
+
function buildAtomicTagsRegExp(atomicTags){
|
|
124
|
+
// Require a delimiter after the name (see defaultAtomicTagsRegExp on why not \b).
|
|
125
|
+
return new RegExp('^<(' + atomicTags.replace(/\s*/g, '').replace(/,/g, '|') + ')[\\s/>]');
|
|
126
|
+
}
|
|
75
127
|
|
|
76
128
|
/**
|
|
77
129
|
* Checks if the current word is the beginning of an atomic tag. An atomic tag is one whose
|
|
@@ -126,6 +178,20 @@
|
|
|
126
178
|
return /^\s*<[^>]+\/>\s*$/.test(token);
|
|
127
179
|
}
|
|
128
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Checks if a tag name is an HTML void element. Void elements cannot have content and can skip a
|
|
183
|
+
* closing tag, so an atomic element with a void tag name ends with its opening tag -
|
|
184
|
+
* with or without the XML style '/>'.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} tag The tag name to check.
|
|
187
|
+
*
|
|
188
|
+
* @return {boolean} True if the tag name is a void element.
|
|
189
|
+
*/
|
|
190
|
+
function isVoidTagName(tag){
|
|
191
|
+
return /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/
|
|
192
|
+
.test(tag);
|
|
193
|
+
}
|
|
194
|
+
|
|
129
195
|
/**
|
|
130
196
|
* Checks if a token can be wrapped inside a tag.
|
|
131
197
|
*
|
|
@@ -189,15 +255,71 @@
|
|
|
189
255
|
var currentWord = '';
|
|
190
256
|
var currentAtomicTag = '';
|
|
191
257
|
var currentAtomicTagDepth = 0;
|
|
258
|
+
// The quote character of the attribute value currently being read, or null. Quoted
|
|
259
|
+
// attribute values may contain any character including '>', so no tag boundary or
|
|
260
|
+
// atomic tag detection applies inside them.
|
|
261
|
+
var currentQuote = null;
|
|
262
|
+
// True while reading the atomic tag's own opening tag. Quote tracking in atomic
|
|
263
|
+
// mode is limited to that region: quotes in the element's content (text
|
|
264
|
+
// apostrophes, comments, nested tags) must not affect how the token ends.
|
|
265
|
+
var inAtomicOpeningTag = false;
|
|
192
266
|
var words = [];
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Consumes a character belonging to a quoted attribute value, updating the quote
|
|
270
|
+
* state. Quoted values may contain any character including '>', so as long as a
|
|
271
|
+
* quote is open no tag boundary or atomic tag detection applies.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} char The current character.
|
|
274
|
+
* @param {boolean} canOpenQuote Whether a quote may start at this position.
|
|
275
|
+
*
|
|
276
|
+
* @return {boolean} True if the character was consumed and no other handling
|
|
277
|
+
* applies to it.
|
|
278
|
+
*/
|
|
279
|
+
function consumeAttributeQuote(char, canOpenQuote){
|
|
280
|
+
if (currentQuote){
|
|
281
|
+
if (char === currentQuote){
|
|
282
|
+
currentQuote = null;
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
if (canOpenQuote && (char === '"' || char === "'")){
|
|
287
|
+
currentQuote = char;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
193
293
|
for (var i = 0; i < html.length; i++){
|
|
194
294
|
var char = html[i];
|
|
195
295
|
switch (mode){
|
|
196
296
|
case 'tag':
|
|
197
|
-
|
|
198
|
-
|
|
297
|
+
// Quote handling must come before the atomic tag detection:
|
|
298
|
+
// dataHtmlDiffIdRegExp can match right at the opening quote of the
|
|
299
|
+
// attribute value, which would enter atomic mode with the quote
|
|
300
|
+
// tracking out of sync.
|
|
301
|
+
if (consumeAttributeQuote(char, true)){
|
|
302
|
+
currentWord += char;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
// The atomic tag regexps require a delimiter after the tag name, so the
|
|
306
|
+
// current character must be included in the check: without it a tag
|
|
307
|
+
// ending right at the name (e.g. '<script' + '>') would never match.
|
|
308
|
+
var atomicTag = isStartOfAtomicTag(currentWord + char);
|
|
309
|
+
if (atomicTag && isEndOfTag(char) &&
|
|
310
|
+
(isVoidTagName(atomicTag) || /\/$/.test(currentWord))){
|
|
311
|
+
// The atomic tag itself is void or self-closing: it has no content
|
|
312
|
+
// that could be swallowed, emit it as a complete token.
|
|
313
|
+
currentWord += '>';
|
|
314
|
+
words.push(createToken(currentWord));
|
|
315
|
+
currentWord = '';
|
|
316
|
+
mode = 'char';
|
|
317
|
+
} else if (atomicTag){
|
|
199
318
|
mode = 'atomic_tag';
|
|
200
319
|
currentAtomicTag = atomicTag;
|
|
320
|
+
// we are still inside the atomic tag's own opening tag unless this
|
|
321
|
+
// character just ended it
|
|
322
|
+
inAtomicOpeningTag = !isEndOfTag(char);
|
|
201
323
|
// skip standalone tags like <script> and <style>
|
|
202
324
|
currentAtomicTagDepth = isEndOfTag(char) ? 1 : 0;
|
|
203
325
|
currentWord += char;
|
|
@@ -219,9 +341,17 @@
|
|
|
219
341
|
break;
|
|
220
342
|
case 'atomic_tag':
|
|
221
343
|
currentWord += char;
|
|
222
|
-
//
|
|
344
|
+
// Quotes may only open inside the atomic tag's own opening tag, where
|
|
345
|
+
// attribute values may contain '>' or '/>' and must not affect the
|
|
346
|
+
// depth tracking below. Quote characters in the element's content
|
|
347
|
+
// (text apostrophes, comments) are not attribute quotes.
|
|
348
|
+
if (consumeAttributeQuote(char, inAtomicOpeningTag)){
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
// track the same name nested tags depth;
|
|
223
352
|
// end the atomic token only when it returns to 0.
|
|
224
353
|
if (isEndOfTag(char)){
|
|
354
|
+
inAtomicOpeningTag = false;
|
|
225
355
|
if (isClosingTagOf(currentWord, currentAtomicTag)){
|
|
226
356
|
currentAtomicTagDepth--;
|
|
227
357
|
if (currentAtomicTagDepth <= 0){
|
|
@@ -231,6 +361,15 @@
|
|
|
231
361
|
currentAtomicTagDepth = 0;
|
|
232
362
|
mode = 'char';
|
|
233
363
|
}
|
|
364
|
+
} else if (currentAtomicTagDepth === 0 &&
|
|
365
|
+
(isVoidTagName(currentAtomicTag) || /\/>$/.test(currentWord))){
|
|
366
|
+
// At depth 0 this '>' can only end the atomic tag's own opening
|
|
367
|
+
// tag. When the element is void or self-closing it has no
|
|
368
|
+
// content: end the token so trailing content tokenizes normally.
|
|
369
|
+
words.push(createToken(currentWord));
|
|
370
|
+
currentWord = '';
|
|
371
|
+
currentAtomicTag = '';
|
|
372
|
+
mode = 'char';
|
|
234
373
|
} else if (isOpeningTagOf(currentWord, currentAtomicTag)){
|
|
235
374
|
currentAtomicTagDepth++;
|
|
236
375
|
}
|
|
@@ -314,8 +453,10 @@
|
|
|
314
453
|
}
|
|
315
454
|
|
|
316
455
|
// If the token is an a element, grab it's data attribute to include in the key.
|
|
456
|
+
// Only when <a> is atomic: if it has been excluded from the atomic tags (as done in
|
|
457
|
+
// recursive inner diffs), the token is just the opening tag.
|
|
317
458
|
var a = /^<a.*href=['"]([^"']*)['"]/.exec(token);
|
|
318
|
-
if (a) {
|
|
459
|
+
if (a && atomicTagsRegExp.test('<a ')) {
|
|
319
460
|
return '<a href="' + a[1] + '"></a>';
|
|
320
461
|
}
|
|
321
462
|
|
|
@@ -909,6 +1050,137 @@
|
|
|
909
1050
|
});
|
|
910
1051
|
}
|
|
911
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* Checks whether a token is an atomic tag that opted into the recursive inner diff via
|
|
1055
|
+
* the data-htmldiff-inner-diff attribute. A bare attribute or any value other than
|
|
1056
|
+
* "false" counts as opted in. Opted-in elements nested inside other opted-in elements
|
|
1057
|
+
* are diffed recursively as well, up to the depth cap; beyond it, opted-in tokens are
|
|
1058
|
+
* rendered verbatim like any other atomic token.
|
|
1059
|
+
*
|
|
1060
|
+
* @param {string} tokenString The token string to check.
|
|
1061
|
+
*
|
|
1062
|
+
* @return {boolean} True if the token should get a recursive inner diff.
|
|
1063
|
+
*/
|
|
1064
|
+
function isInnerDiffToken(tokenString){
|
|
1065
|
+
if (innerDiffDepth >= maxInnerDiffDepth || !isStartOfAtomicTag(tokenString)){
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
var attr = dataHtmlDiffInnerDiffRegExp.exec(tokenString);
|
|
1069
|
+
return !!attr && attr[1] !== 'false';
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Finds the index of the '>' that ends the opening tag at the start of the given token
|
|
1074
|
+
* string, skipping any '>' inside quoted attribute values (e.g. title="a > b").
|
|
1075
|
+
*
|
|
1076
|
+
* @param {string} tokenString The token string starting with an opening tag.
|
|
1077
|
+
*
|
|
1078
|
+
* @return {number} The index of the closing '>' of the opening tag, or -1 if there is
|
|
1079
|
+
* none (e.g. an unterminated tag or an unbalanced attribute quote).
|
|
1080
|
+
*/
|
|
1081
|
+
function findOpeningTagEnd(tokenString){
|
|
1082
|
+
var quote = null;
|
|
1083
|
+
for (var i = 0; i < tokenString.length; i++){
|
|
1084
|
+
var char = tokenString[i];
|
|
1085
|
+
// quote is closed
|
|
1086
|
+
if (char === quote){
|
|
1087
|
+
quote = null;
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
// inside quote
|
|
1091
|
+
if (quote){
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
// quote start
|
|
1095
|
+
if (char === '"' || char === "'"){
|
|
1096
|
+
quote = char;
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
// not inside quote, check for tag end
|
|
1100
|
+
if (char === '>'){
|
|
1101
|
+
return i;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return -1;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Splits an atomic token string into its opening tag, inner HTML and closing tag.
|
|
1109
|
+
* A token consisting of a single tag (a void or self-closing element) has an empty
|
|
1110
|
+
* inner HTML and no closing tag.
|
|
1111
|
+
*
|
|
1112
|
+
* @param {string} tokenString The atomic token string, e.g. '<div a="b">content</div>'.
|
|
1113
|
+
*
|
|
1114
|
+
* @return {Object|null} An object with openingTag, innerHtml and closingTag properties,
|
|
1115
|
+
* or null if the token cannot be split (e.g. an unterminated tag).
|
|
1116
|
+
*/
|
|
1117
|
+
function splitAtomicTokenString(tokenString){
|
|
1118
|
+
var openingTagEnd = findOpeningTagEnd(tokenString);
|
|
1119
|
+
if (openingTagEnd === -1) {
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
if (openingTagEnd === tokenString.length - 1) {
|
|
1123
|
+
// The token is a single tag (void or self-closing): the element has no content.
|
|
1124
|
+
return {
|
|
1125
|
+
openingTag: tokenString,
|
|
1126
|
+
innerHtml: '',
|
|
1127
|
+
closingTag: ''
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
var closingTagStart = tokenString.lastIndexOf('<');
|
|
1131
|
+
if (closingTagStart <= openingTagEnd || tokenString[closingTagStart + 1] !== '/') {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
return {
|
|
1135
|
+
openingTag: tokenString.slice(0, openingTagEnd + 1),
|
|
1136
|
+
innerHtml: tokenString.slice(openingTagEnd + 1, closingTagStart),
|
|
1137
|
+
closingTag: tokenString.slice(closingTagStart)
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Renders the recursive inner diff of two matched atomic tokens with equal keys but
|
|
1143
|
+
* different content. The after version's opening and closing tags are emitted with the
|
|
1144
|
+
* diff of the two inner HTML fragments in between. Inside the recursion the default
|
|
1145
|
+
* atomic tags without 'a' are used, so link text is diffed word by word and href-only
|
|
1146
|
+
* changes do not produce any markup. The after version's
|
|
1147
|
+
* data-htmldiff-inner-diff-atomic-tags attribute overrides that list. Nested opted-in
|
|
1148
|
+
* elements are diffed recursively as well, up to a hardcoded depth cap.
|
|
1149
|
+
*
|
|
1150
|
+
* @param {string} beforeString The before version of the atomic token.
|
|
1151
|
+
* @param {string} afterString The after version of the atomic token.
|
|
1152
|
+
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
1153
|
+
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
1154
|
+
*
|
|
1155
|
+
* @return {string} The rendered element with inner differences wrapped in ins/del tags.
|
|
1156
|
+
*/
|
|
1157
|
+
function renderInnerDiff(beforeString, afterString, dataPrefix, className){
|
|
1158
|
+
var before = splitAtomicTokenString(beforeString);
|
|
1159
|
+
var after = splitAtomicTokenString(afterString);
|
|
1160
|
+
if (!before || !after){
|
|
1161
|
+
return afterString;
|
|
1162
|
+
}
|
|
1163
|
+
var atomicTagsOverride = dataHtmlDiffInnerDiffAtomicTagsRegExp.exec(afterString);
|
|
1164
|
+
var outerAtomicTagsRegExp = atomicTagsRegExp;
|
|
1165
|
+
innerDiffDepth++;
|
|
1166
|
+
// we need to clean up the innerDiffDepth update and atomicTagsRegExp restoration in case of an error, hence try/finally
|
|
1167
|
+
try {
|
|
1168
|
+
atomicTagsRegExp = defaultInnerDiffAtomicTagsRegExp;
|
|
1169
|
+
|
|
1170
|
+
if (atomicTagsOverride) {
|
|
1171
|
+
atomicTagsRegExp = atomicTagsOverride[1]
|
|
1172
|
+
? buildAtomicTagsRegExp(atomicTagsOverride[1])
|
|
1173
|
+
: noAtomicTagsRegExp;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
var innerDiff = diffCore(before.innerHtml, after.innerHtml, className, dataPrefix);
|
|
1177
|
+
} finally {
|
|
1178
|
+
innerDiffDepth--;
|
|
1179
|
+
atomicTagsRegExp = outerAtomicTagsRegExp;
|
|
1180
|
+
}
|
|
1181
|
+
return after.openingTag + innerDiff + after.closingTag;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
912
1184
|
/**
|
|
913
1185
|
* OPS.equal/insert/delete/replace are functions that render an operation into
|
|
914
1186
|
* HTML content.
|
|
@@ -931,10 +1203,23 @@
|
|
|
931
1203
|
*/
|
|
932
1204
|
var OPS = {
|
|
933
1205
|
'equal': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1206
|
+
// Tokens in an equal operation pair up one to one between before and after. Equal
|
|
1207
|
+
// keys do not guarantee equal strings (e.g. atomic tokens matched by
|
|
1208
|
+
// data-htmldiff-id): elements that opted in via data-htmldiff-inner-diff get a
|
|
1209
|
+
// recursive diff of their content, everything else renders the after version.
|
|
1210
|
+
var result = '';
|
|
1211
|
+
for (var i = 0; op.startInAfter + i <= op.endInAfter; i++){
|
|
1212
|
+
var afterToken = afterTokens[op.startInAfter + i];
|
|
1213
|
+
var beforeToken = beforeTokens[op.startInBefore + i];
|
|
1214
|
+
if (beforeToken && beforeToken.string !== afterToken.string &&
|
|
1215
|
+
isInnerDiffToken(afterToken.string)){
|
|
1216
|
+
result += renderInnerDiff(
|
|
1217
|
+
beforeToken.string, afterToken.string, dataPrefix, className);
|
|
1218
|
+
} else {
|
|
1219
|
+
result += afterToken.string;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return result;
|
|
938
1223
|
},
|
|
939
1224
|
'insert': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
940
1225
|
var tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
|
|
@@ -1012,13 +1297,24 @@
|
|
|
1012
1297
|
* @return {string} The combined HTML content with differences wrapped in <ins> and <del> tags.
|
|
1013
1298
|
*/
|
|
1014
1299
|
function diff(before, after, className, dataPrefix, atomicTags){
|
|
1015
|
-
if (before === after) return before;
|
|
1016
|
-
|
|
1017
1300
|
// Enable user provided atomic tag list.
|
|
1018
|
-
atomicTags ?
|
|
1019
|
-
(atomicTagsRegExp =
|
|
1301
|
+
atomicTags ?
|
|
1302
|
+
(atomicTagsRegExp = buildAtomicTagsRegExp(atomicTags))
|
|
1020
1303
|
: (atomicTagsRegExp = defaultAtomicTagsRegExp);
|
|
1021
1304
|
|
|
1305
|
+
return diffCore(before, after, className, dataPrefix);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Runs the diff pipeline with whatever atomic tags regular expression is currently active.
|
|
1310
|
+
* Used by the diff function after resolving the atomicTags parameter and by
|
|
1311
|
+
* renderInnerDiff, which sets the regular expression itself.
|
|
1312
|
+
*
|
|
1313
|
+
* @see function diff for the parameter and return value descriptions.
|
|
1314
|
+
*/
|
|
1315
|
+
function diffCore(before, after, className, dataPrefix){
|
|
1316
|
+
if (before === after) return before;
|
|
1317
|
+
|
|
1022
1318
|
before = htmlToTokens(before);
|
|
1023
1319
|
after = htmlToTokens(after);
|
|
1024
1320
|
var ops = calculateOperations(before, after);
|
package/package.json
CHANGED
package/test/diff.spec.js
CHANGED
|
@@ -256,4 +256,63 @@ describe('Diff', function(){
|
|
|
256
256
|
});
|
|
257
257
|
}); // describe('Adjacent atomic tag combining')
|
|
258
258
|
|
|
259
|
+
describe('Atomic tag list override (atomicTags parameter)', function(){
|
|
260
|
+
// Regression tests for the parameter regexp being built with a literal backspace
|
|
261
|
+
// ('\b' instead of '\\b'), which made every override match nothing.
|
|
262
|
+
it('treats a listed tag as atomic', function(){
|
|
263
|
+
var res = cut('<iframe src="a.html"></iframe>', '<iframe src="b.html"></iframe>',
|
|
264
|
+
null, null, 'iframe');
|
|
265
|
+
expect(res).to.equal(
|
|
266
|
+
'<del data-operation-index="0"><iframe src="a.html"></iframe></del>' +
|
|
267
|
+
'<ins data-operation-index="0"><iframe src="b.html"></iframe></ins>');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('replaces the default list, so an unlisted default tag loses atomicity', function(){
|
|
271
|
+
var res = cut('<iframe src="a.html">x</iframe>', '<iframe src="a.html">y</iframe>',
|
|
272
|
+
null, null, 'p');
|
|
273
|
+
expect(res).to.equal(
|
|
274
|
+
'<iframe src="a.html">' +
|
|
275
|
+
'<del data-operation-index="1">x</del>' +
|
|
276
|
+
'<ins data-operation-index="1">y</ins></iframe>');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('does not match tags that merely start with a listed name', function(){
|
|
280
|
+
// 'if' must not make <iframe> atomic: tag names are matched completely.
|
|
281
|
+
var res = cut('<iframe src="a.html">x</iframe>', '<iframe src="a.html">y</iframe>',
|
|
282
|
+
null, null, 'if');
|
|
283
|
+
expect(res).to.equal(
|
|
284
|
+
'<iframe src="a.html">' +
|
|
285
|
+
'<del data-operation-index="1">x</del>' +
|
|
286
|
+
'<ins data-operation-index="1">y</ins></iframe>');
|
|
287
|
+
});
|
|
288
|
+
}); // describe('Atomic tag list override (atomicTags parameter)')
|
|
289
|
+
|
|
290
|
+
describe('Tags sharing a prefix with atomic tag names', function(){
|
|
291
|
+
it('should diff <abbr> content although a is an atomic tag', function(){
|
|
292
|
+
expect(cut('<abbr>old</abbr> t', '<abbr>new</abbr> t')).to.equal(
|
|
293
|
+
'<abbr><del data-operation-index="1">old</del>' +
|
|
294
|
+
'<ins data-operation-index="1">new</ins></abbr> t');
|
|
295
|
+
});
|
|
296
|
+
}); // describe('Tags sharing a prefix with atomic tag names')
|
|
297
|
+
|
|
298
|
+
describe('Quoted attribute values containing ">"', function(){
|
|
299
|
+
it('should diff the content of a tag with ">" in an attribute value', function(){
|
|
300
|
+
expect(cut('<p title="a>b">old</p>', '<p title="a>b">new</p>')).to.equal(
|
|
301
|
+
'<p title="a>b">' +
|
|
302
|
+
'<del data-operation-index="1">old</del>' +
|
|
303
|
+
'<ins data-operation-index="1">new</ins></p>');
|
|
304
|
+
});
|
|
305
|
+
}); // describe('Quoted attribute values containing ">"')
|
|
306
|
+
|
|
307
|
+
describe('Void atomic elements', function(){
|
|
308
|
+
it('should diff text following a void data-htmldiff-id element', function(){
|
|
309
|
+
var res = cut('<img data-htmldiff-id="1" src="a.jpg"> old',
|
|
310
|
+
'<img data-htmldiff-id="1" src="a.jpg"> new');
|
|
311
|
+
expect(res).to.equal(
|
|
312
|
+
'<img data-htmldiff-id="1" src="a.jpg"> ' +
|
|
313
|
+
'<del data-operation-index="1">old</del>' +
|
|
314
|
+
'<ins data-operation-index="1">new</ins>');
|
|
315
|
+
});
|
|
316
|
+
}); // describe('Void atomic elements')
|
|
317
|
+
|
|
259
318
|
}); // describe('Diff')
|
|
@@ -161,5 +161,78 @@ describe('htmlToTokens', function(){
|
|
|
161
161
|
expect(cut(atomic)).eql(tokenize([atomic]));
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
describe('tags sharing a prefix with atomic tag names', function(){
|
|
166
|
+
it('should not treat <abbr> as the atomic tag a', function(){
|
|
167
|
+
expect(cut('<abbr>x</abbr> tail')).eql(
|
|
168
|
+
tokenize(['<abbr>', 'x', '</abbr>', ' ', 'tail']));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should not treat <article> as the atomic tag a', function(){
|
|
172
|
+
expect(cut('<article>hi</article>')).eql(
|
|
173
|
+
tokenize(['<article>', 'hi', '</article>']));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('self-closing atomic tags', function(){
|
|
178
|
+
it('should end a self-closing data-htmldiff-id tag without swallowing trailing content', function(){
|
|
179
|
+
expect(cut('<div data-htmldiff-id="s"/>x old')).eql(
|
|
180
|
+
tokenize(['<div data-htmldiff-id="s"/>', 'x', ' ', 'old']));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should end a self-closing name-based atomic tag without swallowing trailing content', function(){
|
|
184
|
+
expect(cut('<svg/>tail')).eql(tokenize(['<svg/>', 'tail']));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('quoted attribute values containing tag delimiters', function(){
|
|
189
|
+
it('should not end a tag on ">" inside a double-quoted attribute value', function(){
|
|
190
|
+
expect(cut('<p class="a>b">text</p>')).eql(
|
|
191
|
+
tokenize(['<p class="a>b">', 'text', '</p>']));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should not end a tag on ">" inside a single-quoted attribute value', function(){
|
|
195
|
+
expect(cut("<p class='a>b'>x</p>")).eql(
|
|
196
|
+
tokenize(["<p class='a>b'>", 'x', '</p>']));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should not end a void atomic tag on ">" inside an attribute value', function(){
|
|
200
|
+
expect(cut('<img data-htmldiff-id="1" alt="a > b"> tail')).eql(
|
|
201
|
+
tokenize(['<img data-htmldiff-id="1" alt="a > b">', ' ', 'tail']));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should not treat "/>" inside an attribute value as self-closing', function(){
|
|
205
|
+
var atomic = '<span data-htmldiff-id="1" data-x="y/>z">c</span>';
|
|
206
|
+
expect(cut(atomic)).eql(tokenize([atomic]));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should keep an atomic tag with ">" in an attribute as one token', function(){
|
|
210
|
+
expect(cut('<div data-htmldiff-id="1" title="a > b">x</div> tail')).eql(
|
|
211
|
+
tokenize(['<div data-htmldiff-id="1" title="a > b">x</div>',
|
|
212
|
+
' ', 'tail']));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should not treat apostrophes in atomic text content as quotes', function(){
|
|
216
|
+
expect(cut("<div data-htmldiff-id='1'>it's ok</div> tail")).eql(
|
|
217
|
+
tokenize(["<div data-htmldiff-id='1'>it's ok</div>", ' ', 'tail']));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should not treat apostrophes in comments inside atomic tags as quotes', function(){
|
|
221
|
+
expect(cut("<math><mi>x<!-- don't --></mi></math> tail")).eql(
|
|
222
|
+
tokenize(["<math><mi>x<!-- don't --></mi></math>", ' ', 'tail']));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('void atomic tags', function(){
|
|
227
|
+
it('should end a void data-htmldiff-id tag written without a slash', function(){
|
|
228
|
+
expect(cut('<img data-htmldiff-id="1" src="a.jpg"> tail')).eql(
|
|
229
|
+
tokenize(['<img data-htmldiff-id="1" src="a.jpg">', ' ', 'tail']));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should end a void data-htmldiff-id br tag without swallowing trailing content', function(){
|
|
233
|
+
expect(cut('<br data-htmldiff-id="x">y')).eql(
|
|
234
|
+
tokenize(['<br data-htmldiff-id="x">', 'y']));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
164
237
|
});
|
|
165
238
|
});
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
describe('Recursive inner diff (data-htmldiff-inner-diff)', function(){
|
|
2
|
+
var cut;
|
|
3
|
+
|
|
4
|
+
beforeEach(function(){
|
|
5
|
+
cut = require('../js/htmldiff');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
function tocEntry(href, name){
|
|
9
|
+
return '<div class="toc-entry" data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
10
|
+
'<a href="' + href + '">' + name + '</a></div>';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('when an opted-in element is renamed in place', function(){
|
|
14
|
+
it('renders inline ins/del inside the single emitted element', function(){
|
|
15
|
+
var res = cut(tocEntry('#123', '1. Old name'), tocEntry('#123', '1. New name'));
|
|
16
|
+
expect(res).to.equal(
|
|
17
|
+
'<div class="toc-entry" data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
18
|
+
'<a href="#123">1. <del data-operation-index="1">Old</del>' +
|
|
19
|
+
'<ins data-operation-index="1">New</ins> name</a></div>');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('passes className and dataPrefix through to the inner ins/del tags', function(){
|
|
23
|
+
var res = cut(tocEntry('#123', 'Old'), tocEntry('#123', 'New'), 'diff-cls', 'pre');
|
|
24
|
+
expect(res).to.equal(
|
|
25
|
+
'<div class="toc-entry" data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
26
|
+
'<a href="#123"><del data-pre-operation-index="1" class="diff-cls">Old</del>' +
|
|
27
|
+
'<ins data-pre-operation-index="1" class="diff-cls">New</ins></a></div>');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps diffing the rest of the document with the outer atomic tag list', function(){
|
|
31
|
+
// The <a> outside the opted-in element must stay atomic after the inner diff ran.
|
|
32
|
+
var res = cut(
|
|
33
|
+
tocEntry('#123', 'Old name') + '<a href="x.html">same link</a>',
|
|
34
|
+
tocEntry('#123', 'New name') + '<a href="y.html">same link</a>');
|
|
35
|
+
expect(res).to.equal(
|
|
36
|
+
'<div class="toc-entry" data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
37
|
+
'<a href="#123"><del data-operation-index="1">Old</del>' +
|
|
38
|
+
'<ins data-operation-index="1">New</ins> name</a></div>' +
|
|
39
|
+
'<del data-operation-index="1"><a href="x.html">same link</a></del>' +
|
|
40
|
+
'<ins data-operation-index="1"><a href="y.html">same link</a></ins>');
|
|
41
|
+
});
|
|
42
|
+
}); // describe('when an opted-in element is renamed in place')
|
|
43
|
+
|
|
44
|
+
describe('anchors inside the recursive diff', function(){
|
|
45
|
+
it('do not treat <a> as atomic, so href-only changes produce no diff markup', function(){
|
|
46
|
+
var res = cut(tocEntry('#18036', '1. Same name'), tocEntry('#18037', '1. Same name'));
|
|
47
|
+
expect(res).to.equal(tocEntry('#18037', '1. Same name'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('diffs the link text inline when the href changed', function(){
|
|
51
|
+
var res = cut(tocEntry('#18036', '1. Old name'), tocEntry('#18037', '1. New name'));
|
|
52
|
+
expect(res).to.equal(
|
|
53
|
+
'<div class="toc-entry" data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
54
|
+
'<a href="#18037">1. <del data-operation-index="1">Old</del>' +
|
|
55
|
+
'<ins data-operation-index="1">New</ins> name</a></div>');
|
|
56
|
+
});
|
|
57
|
+
}); // describe('anchors inside the recursive diff')
|
|
58
|
+
|
|
59
|
+
describe('atomic tags inside the recursive diff', function(){
|
|
60
|
+
function el(extraAttrs, inner){
|
|
61
|
+
return '<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"' + extraAttrs +
|
|
62
|
+
'>' + inner + '</div>';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('keeps the default atomic tags (without a) inside the recursion', function(){
|
|
66
|
+
// Embedded content like svg stays atomic by default: a changed svg is replaced
|
|
67
|
+
// as a whole, not word-diffed.
|
|
68
|
+
var res = cut(
|
|
69
|
+
el('', '<svg viewBox="0 0 1 1"><text>old</text></svg>'),
|
|
70
|
+
el('', '<svg viewBox="0 0 1 1"><text>new</text></svg>'));
|
|
71
|
+
expect(res).to.equal(
|
|
72
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
73
|
+
'<del data-operation-index="0">' +
|
|
74
|
+
'<svg viewBox="0 0 1 1"><text>old</text></svg></del>' +
|
|
75
|
+
'<ins data-operation-index="0">' +
|
|
76
|
+
'<svg viewBox="0 0 1 1"><text>new</text></svg></ins>' +
|
|
77
|
+
'</div>');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('replaces the default list with data-htmldiff-inner-diff-atomic-tags', function(){
|
|
81
|
+
// The override lists only em, so svg is no longer atomic and gets word-diffed.
|
|
82
|
+
var attrs = ' data-htmldiff-inner-diff-atomic-tags="em"';
|
|
83
|
+
var res = cut(
|
|
84
|
+
el(attrs, '<svg viewBox="0 0 1 1"><text>old</text></svg>'),
|
|
85
|
+
el(attrs, '<svg viewBox="0 0 1 1"><text>new</text></svg>'));
|
|
86
|
+
expect(res).to.equal(
|
|
87
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"' + attrs + '>' +
|
|
88
|
+
'<svg viewBox="0 0 1 1"><text>' +
|
|
89
|
+
'<del data-operation-index="1">old</del>' +
|
|
90
|
+
'<ins data-operation-index="1">new</ins>' +
|
|
91
|
+
'</text></svg></div>');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('restores atomic anchors (href comparison) when the override lists a', function(){
|
|
95
|
+
var attrs = ' data-htmldiff-inner-diff-atomic-tags="a"';
|
|
96
|
+
var res = cut(
|
|
97
|
+
el(attrs, '<a href="#1">Name</a>'),
|
|
98
|
+
el(attrs, '<a href="#2">Name</a>'));
|
|
99
|
+
expect(res).to.equal(
|
|
100
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"' + attrs + '>' +
|
|
101
|
+
'<del data-operation-index="0"><a href="#1">Name</a></del>' +
|
|
102
|
+
'<ins data-operation-index="0"><a href="#2">Name</a></ins>' +
|
|
103
|
+
'</div>');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('treats no tag name as atomic when the override value is empty', function(){
|
|
107
|
+
var attrs = ' data-htmldiff-inner-diff-atomic-tags=""';
|
|
108
|
+
var res = cut(
|
|
109
|
+
el(attrs, '<svg viewBox="0 0 1 1"><text>old</text></svg>'),
|
|
110
|
+
el(attrs, '<svg viewBox="0 0 1 1"><text>new</text></svg>'));
|
|
111
|
+
expect(res).to.equal(
|
|
112
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"' + attrs + '>' +
|
|
113
|
+
'<svg viewBox="0 0 1 1"><text>' +
|
|
114
|
+
'<del data-operation-index="1">old</del>' +
|
|
115
|
+
'<ins data-operation-index="1">new</ins>' +
|
|
116
|
+
'</text></svg></div>');
|
|
117
|
+
});
|
|
118
|
+
}); // describe('atomic tags inside the recursive diff')
|
|
119
|
+
|
|
120
|
+
describe('opt-in attribute values', function(){
|
|
121
|
+
function entry(value, name){
|
|
122
|
+
return '<div data-htmldiff-id="123" data-htmldiff-inner-diff=' + value + '>' +
|
|
123
|
+
name + '</div>';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('treats a double-quoted "false" value as opted out', function(){
|
|
127
|
+
var res = cut(entry('"false"', 'old'), entry('"false"', 'new'));
|
|
128
|
+
expect(res).to.equal(entry('"false"', 'new'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('treats a single-quoted \'false\' value as opted out', function(){
|
|
132
|
+
var res = cut(entry("'false'", 'old'), entry("'false'", 'new'));
|
|
133
|
+
expect(res).to.equal(entry("'false'", 'new'));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('treats an unquoted false value as opted out', function(){
|
|
137
|
+
var res = cut(entry('false', 'old'), entry('false', 'new'));
|
|
138
|
+
expect(res).to.equal(entry('false', 'new'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('treats a bare attribute without a value as opted in', function(){
|
|
142
|
+
var res = cut(
|
|
143
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff>old</div>',
|
|
144
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff>new</div>');
|
|
145
|
+
expect(res).to.equal(
|
|
146
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff>' +
|
|
147
|
+
'<del data-operation-index="0">old</del>' +
|
|
148
|
+
'<ins data-operation-index="0">new</ins></div>');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('only matches exact attribute name', function(){
|
|
152
|
+
var res = cut(
|
|
153
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff-extra="true">old</div>',
|
|
154
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff-extra="true">new</div>');
|
|
155
|
+
expect(res).to.equal(
|
|
156
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff-extra="true">new</div>');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('recognizes the attribute regardless of its position', function(){
|
|
160
|
+
var res = cut(
|
|
161
|
+
'<div data-htmldiff-inner-diff="true" data-htmldiff-id="123">old</div>',
|
|
162
|
+
'<div data-htmldiff-inner-diff="true" data-htmldiff-id="123">new</div>');
|
|
163
|
+
expect(res).to.equal(
|
|
164
|
+
'<div data-htmldiff-inner-diff="true" data-htmldiff-id="123">' +
|
|
165
|
+
'<del data-operation-index="0">old</del>' +
|
|
166
|
+
'<ins data-operation-index="0">new</ins></div>');
|
|
167
|
+
});
|
|
168
|
+
}); // describe('opt-in attribute values')
|
|
169
|
+
|
|
170
|
+
describe('edge cases', function(){
|
|
171
|
+
it('ignores the attribute on non-atomic elements (no data-htmldiff-id)', function(){
|
|
172
|
+
// Without data-htmldiff-id the div is not atomic, so this is a plain word diff.
|
|
173
|
+
var res = cut(
|
|
174
|
+
'<div data-htmldiff-inner-diff="true">old text</div>',
|
|
175
|
+
'<div data-htmldiff-inner-diff="true">new text</div>');
|
|
176
|
+
expect(res).to.equal(
|
|
177
|
+
'<div data-htmldiff-inner-diff="true">' +
|
|
178
|
+
'<del data-operation-index="1">old</del>' +
|
|
179
|
+
'<ins data-operation-index="1">new</ins> text</div>');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('diffs text following a self-closing opted-in element normally', function(){
|
|
183
|
+
var res = cut(
|
|
184
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"/>x old',
|
|
185
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"/>x new');
|
|
186
|
+
expect(res).to.equal(
|
|
187
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"/>x ' +
|
|
188
|
+
'<del data-operation-index="1">old</del>' +
|
|
189
|
+
'<ins data-operation-index="1">new</ins>');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('marks the content as inserted when the before element was self-closing', function(){
|
|
193
|
+
// A self-closing element has empty inner content, so the new content is a
|
|
194
|
+
// pure insertion.
|
|
195
|
+
var res = cut(
|
|
196
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"/>',
|
|
197
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true">new</div>');
|
|
198
|
+
expect(res).to.equal(
|
|
199
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
200
|
+
'<ins data-operation-index="0">new</ins></div>');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('marks the content as deleted when the after element became self-closing', function(){
|
|
204
|
+
// The after element has no content anymore, so the deleted content is
|
|
205
|
+
// rendered right after the self-closing tag.
|
|
206
|
+
var res = cut(
|
|
207
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true">old</div>',
|
|
208
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"/>');
|
|
209
|
+
expect(res).to.equal(
|
|
210
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true"/>' +
|
|
211
|
+
'<del data-operation-index="0">old</del>');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('is not confused by ">" inside attribute values', function(){
|
|
215
|
+
var res = cut(
|
|
216
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true" title="a > b">' +
|
|
217
|
+
'old</div>',
|
|
218
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true" title="a > c">' +
|
|
219
|
+
'new</div>');
|
|
220
|
+
expect(res).to.equal(
|
|
221
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true" title="a > c">' +
|
|
222
|
+
'<del data-operation-index="0">old</del>' +
|
|
223
|
+
'<ins data-operation-index="0">new</ins></div>');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('is not confused by ">" inside single-quoted attribute values', function(){
|
|
227
|
+
var res = cut(
|
|
228
|
+
"<div data-htmldiff-id='123' data-htmldiff-inner-diff title='a > b'>old</div>",
|
|
229
|
+
"<div data-htmldiff-id='123' data-htmldiff-inner-diff title='a > b'>new</div>");
|
|
230
|
+
expect(res).to.equal(
|
|
231
|
+
"<div data-htmldiff-id='123' data-htmldiff-inner-diff title='a > b'>" +
|
|
232
|
+
'<del data-operation-index="0">old</del>' +
|
|
233
|
+
'<ins data-operation-index="0">new</ins></div>');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('falls back to the after version when a token cannot be split', function(){
|
|
237
|
+
// An unterminated atomic tag swallows the rest of the input and has no closing
|
|
238
|
+
// tag to split on; the inner diff falls back instead of producing broken markup.
|
|
239
|
+
var after = '<div data-htmldiff-id="123" data-htmldiff-inner-diff="true">new';
|
|
240
|
+
var res = cut(
|
|
241
|
+
'<div data-htmldiff-id="123" data-htmldiff-inner-diff="true">old',
|
|
242
|
+
after);
|
|
243
|
+
expect(res).to.equal(after);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('renders pure insertions when the before content is empty', function(){
|
|
247
|
+
var res = cut(tocEntry('#1', ''), tocEntry('#1', 'New name'));
|
|
248
|
+
expect(res).to.equal(
|
|
249
|
+
'<div class="toc-entry" data-htmldiff-id="123" data-htmldiff-inner-diff="true">' +
|
|
250
|
+
'<a href="#1"><ins data-operation-index="1">New name</ins></a></div>');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('do not diffs inner html for moved opted-in elements', function(){
|
|
254
|
+
function entry(id, name){
|
|
255
|
+
return '<div data-htmldiff-id="' + id + '" data-htmldiff-inner-diff="true">' +
|
|
256
|
+
name + '</div>';
|
|
257
|
+
}
|
|
258
|
+
var res = cut(entry('a', 'First') + entry('b', 'Second'),
|
|
259
|
+
entry('b', 'Second') + entry('a', 'First'));
|
|
260
|
+
expect(res).to.equal(
|
|
261
|
+
'<ins data-operation-index="0">' + entry('b', 'Second') + '</ins>' +
|
|
262
|
+
entry('a', 'First') +
|
|
263
|
+
'<del data-operation-index="2">' + entry('b', 'Second') + '</del>');
|
|
264
|
+
});
|
|
265
|
+
}); // describe('edge cases')
|
|
266
|
+
|
|
267
|
+
describe('when the element did not opt in', function(){
|
|
268
|
+
it('should render the after version as is when keys match but content differs', function(){
|
|
269
|
+
var before = '<div data-htmldiff-id="chart-1"><svg viewBox="0 0 1 1">' +
|
|
270
|
+
'<rect width="5"/></svg></div>';
|
|
271
|
+
var after = '<div data-htmldiff-id="chart-1"><svg viewBox="0 0 2 2">' +
|
|
272
|
+
'<rect width="9"/></svg></div>';
|
|
273
|
+
expect(cut(before, after)).to.equal(after);
|
|
274
|
+
});
|
|
275
|
+
}); // describe('when the element did not opt in')
|
|
276
|
+
|
|
277
|
+
describe('when key and content are both equal', function(){
|
|
278
|
+
it('should render the element unchanged', function(){
|
|
279
|
+
var before = 'x ' + tocEntry('#123', '1. Same name') + ' y';
|
|
280
|
+
var after = 'x ' + tocEntry('#123', '1. Same name') + ' z';
|
|
281
|
+
expect(cut(before, after)).to.equal(
|
|
282
|
+
'x ' + tocEntry('#123', '1. Same name') + ' ' +
|
|
283
|
+
'<del data-operation-index="1">y</del><ins data-operation-index="1">z</ins>');
|
|
284
|
+
});
|
|
285
|
+
}); // describe('when key and content are both equal')
|
|
286
|
+
|
|
287
|
+
describe('when opted-in elements are nested', function(){
|
|
288
|
+
function nest(depth, content){
|
|
289
|
+
var html = content;
|
|
290
|
+
for (var i = depth; i >= 1; i--){
|
|
291
|
+
html = '<div data-htmldiff-id="d' + i + '" data-htmldiff-inner-diff="true">' +
|
|
292
|
+
html + '</div>';
|
|
293
|
+
}
|
|
294
|
+
return html;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it('diffs nested elements', function(){
|
|
298
|
+
var res = cut(
|
|
299
|
+
'<div data-htmldiff-id="outer" data-htmldiff-inner-diff="true">x ' +
|
|
300
|
+
'<span data-htmldiff-id="inner" data-htmldiff-inner-diff="true">old</span></div>',
|
|
301
|
+
'<div data-htmldiff-id="outer" data-htmldiff-inner-diff="true">x ' +
|
|
302
|
+
'<span data-htmldiff-id="inner" data-htmldiff-inner-diff="true">new</span></div>');
|
|
303
|
+
expect(res).to.equal(
|
|
304
|
+
'<div data-htmldiff-id="outer" data-htmldiff-inner-diff="true">x ' +
|
|
305
|
+
'<span data-htmldiff-id="inner" data-htmldiff-inner-diff="true">' +
|
|
306
|
+
'<del data-operation-index="0">old</del>' +
|
|
307
|
+
'<ins data-operation-index="0">new</ins></span></div>');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('do not diffs nested element that do not opt-in', function(){
|
|
311
|
+
var after = '<div data-htmldiff-id="outer" data-htmldiff-inner-diff="true">t ' +
|
|
312
|
+
'<span data-htmldiff-id="inner">new</span></div>';
|
|
313
|
+
var res = cut(
|
|
314
|
+
'<div data-htmldiff-id="outer" data-htmldiff-inner-diff="true">t ' +
|
|
315
|
+
'<span data-htmldiff-id="inner">old</span></div>',
|
|
316
|
+
after);
|
|
317
|
+
expect(res).to.equal(after);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('diffs all levels up to the depth cap of 10', function(){
|
|
321
|
+
var res = cut(nest(10, 'old'), nest(10, 'new'));
|
|
322
|
+
expect(res).to.equal(nest(10,
|
|
323
|
+
'<del data-operation-index="0">old</del>' +
|
|
324
|
+
'<ins data-operation-index="0">new</ins>'));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('renders the after version as is beyond the depth cap', function(){
|
|
328
|
+
var res = cut(nest(11, 'old'), nest(11, 'new'));
|
|
329
|
+
expect(res).to.equal(nest(11, 'new'));
|
|
330
|
+
});
|
|
331
|
+
}); // describe('when opted-in elements are nested')
|
|
332
|
+
|
|
333
|
+
}); // describe('Recursive inner diff (data-htmldiff-inner-diff)')
|