@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 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 `&nbsp;`/`&#160;`
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
- var defaultAtomicTagsRegExp = new RegExp('^<(iframe|object|math|svg|script|video|head|style|a)\\b');
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
- var atomicTag = isStartOfAtomicTag(currentWord);
198
- if (atomicTag){
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
- // track the same name nested tags depth;
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
- var tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
935
- return tokens.reduce(function(prev, curr){
936
- return prev + curr.string;
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 = new RegExp('^<(' + atomicTags.replace(/\s*/g, '').replace(/,/g, '|') + ')\b'))
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
@@ -2,7 +2,7 @@
2
2
  "name": "@matrixreq/htmldiff",
3
3
  "description": "Diff and markup HTML with <ins> and <del> tags",
4
4
  "companyname": "Matrix Requirements",
5
- "version": "1.4.0",
5
+ "version": "1.5.0",
6
6
  "keywords": [
7
7
  "diff",
8
8
  "html",
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)')