@matrixreq/htmldiff 1.4.0 → 1.6.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,70 @@
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');
74
- const dataHtmlDiffIdRegExp = /^<([a-z\-]+).+data-htmldiff-id=["']?((?:.(?!["']?\s+(?:\S+)=|\s*\/?[>"']))*.)["']?/;
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;
77
+
78
+ /**
79
+ * Matches an element whose own opening tag carries data-htmldiff-id; captures tag name and
80
+ * attribute value. The skip before the attribute is quote aware, so it cannot run past '>'
81
+ * into a nested child. The leading \s prevents matching 'x-data-htmldiff-id'.
82
+ * @see isStartOfAtomicTag and createToken.
83
+ */
84
+ const dataHtmlDiffIdRegExp =
85
+ /^<([a-z\-]+)(?:[^>"']|"[^"]*"|'[^']*')*\sdata-htmldiff-id=["']?((?:.(?!["']?\s+(?:\S+)=|\s*\/?[>"']))*.)["']?/;
86
+
87
+ /**
88
+ * Opt-in marker for the recursive inner diff. When two matched atomic tokens (typically
89
+ * matched by data-htmldiff-id) have equal keys but different content, an element carrying
90
+ * this attribute gets its inner HTML diffed recursively instead of being rendered as is.
91
+ * The attribute must appear in the element's opening tag. Captures the attribute value;
92
+ * a bare attribute or any value other than "false" enables the opt-in.
93
+ * @see OPS.equal and renderInnerDiff.
94
+ */
95
+ const dataHtmlDiffInnerDiffRegExp =
96
+ /^<[^>]*\sdata-htmldiff-inner-diff(?:\s*=\s*["']?([^"'\s/>]*)|(?=[\s/>]))/;
97
+
98
+ /**
99
+ * Per-element override for the atomic tags used inside a recursive inner diff. The value
100
+ * is a comma separated tag name list, like the atomicTags parameter of the diff function;
101
+ * an empty value means no tag name is atomic. Without the attribute the default list
102
+ * without 'a' is used, so anchors have their text content diffed word by word while
103
+ * embedded content like svg stays atomic. The attribute is only consulted on elements
104
+ * that opted in via data-htmldiff-inner-diff.
105
+ */
106
+ const dataHtmlDiffInnerDiffAtomicTagsRegExp =
107
+ /^<[^>]*\sdata-htmldiff-inner-diff-atomic-tags\s*=\s*["']([^"']*)["']/;
108
+
109
+ // Atomic tags used inside a recursive inner diff unless the element overrides them via
110
+ // data-htmldiff-inner-diff-atomic-tags: the default list without 'a'.
111
+ var defaultInnerDiffAtomicTagsRegExp =
112
+ new RegExp('^<(iframe|object|math|svg|script|video|head|style)[\\s/>]');
113
+
114
+ // Matches no tag at all: used when data-htmldiff-inner-diff-atomic-tags is empty.
115
+ const noAtomicTagsRegExp = /^<(?!)/;
116
+
117
+ // The number of currently active recursive inner diffs. Recursion is governed per
118
+ // element (each nesting level requires its own data-htmldiff-inner-diff attribute), so
119
+ // the depth is naturally bounded by the nesting of opted-in elements; the cap is only a
120
+ // backstop against pathologically deep documents.
121
+ var innerDiffDepth = 0;
122
+ const maxInnerDiffDepth = 10;
123
+
124
+ /**
125
+ * Builds the atomic tags regular expression from a comma separated tag name list.
126
+ *
127
+ * @param {string} atomicTags Comma separated list of tag names, e.g. 'head,script,style'.
128
+ *
129
+ * @return {RegExp} The regular expression matching the start of those tags.
130
+ */
131
+ function buildAtomicTagsRegExp(atomicTags){
132
+ // Require a delimiter after the name (see defaultAtomicTagsRegExp on why not \b).
133
+ return new RegExp('^<(' + atomicTags.replace(/\s*/g, '').replace(/,/g, '|') + ')[\\s/>]');
134
+ }
75
135
 
76
136
  /**
77
137
  * Checks if the current word is the beginning of an atomic tag. An atomic tag is one whose
@@ -126,6 +186,20 @@
126
186
  return /^\s*<[^>]+\/>\s*$/.test(token);
127
187
  }
128
188
 
189
+ /**
190
+ * Checks if a tag name is an HTML void element. Void elements cannot have content and can skip a
191
+ * closing tag, so an atomic element with a void tag name ends with its opening tag -
192
+ * with or without the XML style '/>'.
193
+ *
194
+ * @param {string} tag The tag name to check.
195
+ *
196
+ * @return {boolean} True if the tag name is a void element.
197
+ */
198
+ function isVoidTagName(tag){
199
+ return /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/
200
+ .test(tag);
201
+ }
202
+
129
203
  /**
130
204
  * Checks if a token can be wrapped inside a tag.
131
205
  *
@@ -189,15 +263,71 @@
189
263
  var currentWord = '';
190
264
  var currentAtomicTag = '';
191
265
  var currentAtomicTagDepth = 0;
266
+ // The quote character of the attribute value currently being read, or null. Quoted
267
+ // attribute values may contain any character including '>', so no tag boundary or
268
+ // atomic tag detection applies inside them.
269
+ var currentQuote = null;
270
+ // True while reading the atomic tag's own opening tag. Quote tracking in atomic
271
+ // mode is limited to that region: quotes in the element's content (text
272
+ // apostrophes, comments, nested tags) must not affect how the token ends.
273
+ var inAtomicOpeningTag = false;
192
274
  var words = [];
275
+
276
+ /**
277
+ * Consumes a character belonging to a quoted attribute value, updating the quote
278
+ * state. Quoted values may contain any character including '>', so as long as a
279
+ * quote is open no tag boundary or atomic tag detection applies.
280
+ *
281
+ * @param {string} char The current character.
282
+ * @param {boolean} canOpenQuote Whether a quote may start at this position.
283
+ *
284
+ * @return {boolean} True if the character was consumed and no other handling
285
+ * applies to it.
286
+ */
287
+ function consumeAttributeQuote(char, canOpenQuote){
288
+ if (currentQuote){
289
+ if (char === currentQuote){
290
+ currentQuote = null;
291
+ }
292
+ return true;
293
+ }
294
+ if (canOpenQuote && (char === '"' || char === "'")){
295
+ currentQuote = char;
296
+ return true;
297
+ }
298
+ return false;
299
+ }
300
+
193
301
  for (var i = 0; i < html.length; i++){
194
302
  var char = html[i];
195
303
  switch (mode){
196
304
  case 'tag':
197
- var atomicTag = isStartOfAtomicTag(currentWord);
198
- if (atomicTag){
305
+ // Quote handling must come before the atomic tag detection:
306
+ // dataHtmlDiffIdRegExp can match right at the opening quote of the
307
+ // attribute value, which would enter atomic mode with the quote
308
+ // tracking out of sync.
309
+ if (consumeAttributeQuote(char, true)){
310
+ currentWord += char;
311
+ break;
312
+ }
313
+ // The atomic tag regexps require a delimiter after the tag name, so the
314
+ // current character must be included in the check: without it a tag
315
+ // ending right at the name (e.g. '<script' + '>') would never match.
316
+ var atomicTag = isStartOfAtomicTag(currentWord + char);
317
+ if (atomicTag && isEndOfTag(char) &&
318
+ (isVoidTagName(atomicTag) || /\/$/.test(currentWord))){
319
+ // The atomic tag itself is void or self-closing: it has no content
320
+ // that could be swallowed, emit it as a complete token.
321
+ currentWord += '>';
322
+ words.push(createToken(currentWord));
323
+ currentWord = '';
324
+ mode = 'char';
325
+ } else if (atomicTag){
199
326
  mode = 'atomic_tag';
200
327
  currentAtomicTag = atomicTag;
328
+ // we are still inside the atomic tag's own opening tag unless this
329
+ // character just ended it
330
+ inAtomicOpeningTag = !isEndOfTag(char);
201
331
  // skip standalone tags like <script> and <style>
202
332
  currentAtomicTagDepth = isEndOfTag(char) ? 1 : 0;
203
333
  currentWord += char;
@@ -219,9 +349,17 @@
219
349
  break;
220
350
  case 'atomic_tag':
221
351
  currentWord += char;
222
- // track the same name nested tags depth;
352
+ // Quotes may only open inside the atomic tag's own opening tag, where
353
+ // attribute values may contain '>' or '/>' and must not affect the
354
+ // depth tracking below. Quote characters in the element's content
355
+ // (text apostrophes, comments) are not attribute quotes.
356
+ if (consumeAttributeQuote(char, inAtomicOpeningTag)){
357
+ break;
358
+ }
359
+ // track the same name nested tags depth;
223
360
  // end the atomic token only when it returns to 0.
224
361
  if (isEndOfTag(char)){
362
+ inAtomicOpeningTag = false;
225
363
  if (isClosingTagOf(currentWord, currentAtomicTag)){
226
364
  currentAtomicTagDepth--;
227
365
  if (currentAtomicTagDepth <= 0){
@@ -231,6 +369,15 @@
231
369
  currentAtomicTagDepth = 0;
232
370
  mode = 'char';
233
371
  }
372
+ } else if (currentAtomicTagDepth === 0 &&
373
+ (isVoidTagName(currentAtomicTag) || /\/>$/.test(currentWord))){
374
+ // At depth 0 this '>' can only end the atomic tag's own opening
375
+ // tag. When the element is void or self-closing it has no
376
+ // content: end the token so trailing content tokenizes normally.
377
+ words.push(createToken(currentWord));
378
+ currentWord = '';
379
+ currentAtomicTag = '';
380
+ mode = 'char';
234
381
  } else if (isOpeningTagOf(currentWord, currentAtomicTag)){
235
382
  currentAtomicTagDepth++;
236
383
  }
@@ -314,8 +461,10 @@
314
461
  }
315
462
 
316
463
  // If the token is an a element, grab it's data attribute to include in the key.
464
+ // Only when <a> is atomic: if it has been excluded from the atomic tags (as done in
465
+ // recursive inner diffs), the token is just the opening tag.
317
466
  var a = /^<a.*href=['"]([^"']*)['"]/.exec(token);
318
- if (a) {
467
+ if (a && atomicTagsRegExp.test('<a ')) {
319
468
  return '<a href="' + a[1] + '"></a>';
320
469
  }
321
470
 
@@ -909,6 +1058,137 @@
909
1058
  });
910
1059
  }
911
1060
 
1061
+ /**
1062
+ * Checks whether a token is an atomic tag that opted into the recursive inner diff via
1063
+ * the data-htmldiff-inner-diff attribute. A bare attribute or any value other than
1064
+ * "false" counts as opted in. Opted-in elements nested inside other opted-in elements
1065
+ * are diffed recursively as well, up to the depth cap; beyond it, opted-in tokens are
1066
+ * rendered verbatim like any other atomic token.
1067
+ *
1068
+ * @param {string} tokenString The token string to check.
1069
+ *
1070
+ * @return {boolean} True if the token should get a recursive inner diff.
1071
+ */
1072
+ function isInnerDiffToken(tokenString){
1073
+ if (innerDiffDepth >= maxInnerDiffDepth || !isStartOfAtomicTag(tokenString)){
1074
+ return false;
1075
+ }
1076
+ var attr = dataHtmlDiffInnerDiffRegExp.exec(tokenString);
1077
+ return !!attr && attr[1] !== 'false';
1078
+ }
1079
+
1080
+ /**
1081
+ * Finds the index of the '>' that ends the opening tag at the start of the given token
1082
+ * string, skipping any '>' inside quoted attribute values (e.g. title="a > b").
1083
+ *
1084
+ * @param {string} tokenString The token string starting with an opening tag.
1085
+ *
1086
+ * @return {number} The index of the closing '>' of the opening tag, or -1 if there is
1087
+ * none (e.g. an unterminated tag or an unbalanced attribute quote).
1088
+ */
1089
+ function findOpeningTagEnd(tokenString){
1090
+ var quote = null;
1091
+ for (var i = 0; i < tokenString.length; i++){
1092
+ var char = tokenString[i];
1093
+ // quote is closed
1094
+ if (char === quote){
1095
+ quote = null;
1096
+ continue;
1097
+ }
1098
+ // inside quote
1099
+ if (quote){
1100
+ continue;
1101
+ }
1102
+ // quote start
1103
+ if (char === '"' || char === "'"){
1104
+ quote = char;
1105
+ continue;
1106
+ }
1107
+ // not inside quote, check for tag end
1108
+ if (char === '>'){
1109
+ return i;
1110
+ }
1111
+ }
1112
+ return -1;
1113
+ }
1114
+
1115
+ /**
1116
+ * Splits an atomic token string into its opening tag, inner HTML and closing tag.
1117
+ * A token consisting of a single tag (a void or self-closing element) has an empty
1118
+ * inner HTML and no closing tag.
1119
+ *
1120
+ * @param {string} tokenString The atomic token string, e.g. '<div a="b">content</div>'.
1121
+ *
1122
+ * @return {Object|null} An object with openingTag, innerHtml and closingTag properties,
1123
+ * or null if the token cannot be split (e.g. an unterminated tag).
1124
+ */
1125
+ function splitAtomicTokenString(tokenString){
1126
+ var openingTagEnd = findOpeningTagEnd(tokenString);
1127
+ if (openingTagEnd === -1) {
1128
+ return null;
1129
+ }
1130
+ if (openingTagEnd === tokenString.length - 1) {
1131
+ // The token is a single tag (void or self-closing): the element has no content.
1132
+ return {
1133
+ openingTag: tokenString,
1134
+ innerHtml: '',
1135
+ closingTag: ''
1136
+ };
1137
+ }
1138
+ var closingTagStart = tokenString.lastIndexOf('<');
1139
+ if (closingTagStart <= openingTagEnd || tokenString[closingTagStart + 1] !== '/') {
1140
+ return null;
1141
+ }
1142
+ return {
1143
+ openingTag: tokenString.slice(0, openingTagEnd + 1),
1144
+ innerHtml: tokenString.slice(openingTagEnd + 1, closingTagStart),
1145
+ closingTag: tokenString.slice(closingTagStart)
1146
+ };
1147
+ }
1148
+
1149
+ /**
1150
+ * Renders the recursive inner diff of two matched atomic tokens with equal keys but
1151
+ * different content. The after version's opening and closing tags are emitted with the
1152
+ * diff of the two inner HTML fragments in between. Inside the recursion the default
1153
+ * atomic tags without 'a' are used, so link text is diffed word by word and href-only
1154
+ * changes do not produce any markup. The after version's
1155
+ * data-htmldiff-inner-diff-atomic-tags attribute overrides that list. Nested opted-in
1156
+ * elements are diffed recursively as well, up to a hardcoded depth cap.
1157
+ *
1158
+ * @param {string} beforeString The before version of the atomic token.
1159
+ * @param {string} afterString The after version of the atomic token.
1160
+ * @param {string} dataPrefix (Optional) The prefix to use in data attributes.
1161
+ * @param {string} className (Optional) The class name to include in the wrapper tag.
1162
+ *
1163
+ * @return {string} The rendered element with inner differences wrapped in ins/del tags.
1164
+ */
1165
+ function renderInnerDiff(beforeString, afterString, dataPrefix, className){
1166
+ var before = splitAtomicTokenString(beforeString);
1167
+ var after = splitAtomicTokenString(afterString);
1168
+ if (!before || !after){
1169
+ return afterString;
1170
+ }
1171
+ var atomicTagsOverride = dataHtmlDiffInnerDiffAtomicTagsRegExp.exec(afterString);
1172
+ var outerAtomicTagsRegExp = atomicTagsRegExp;
1173
+ innerDiffDepth++;
1174
+ // we need to clean up the innerDiffDepth update and atomicTagsRegExp restoration in case of an error, hence try/finally
1175
+ try {
1176
+ atomicTagsRegExp = defaultInnerDiffAtomicTagsRegExp;
1177
+
1178
+ if (atomicTagsOverride) {
1179
+ atomicTagsRegExp = atomicTagsOverride[1]
1180
+ ? buildAtomicTagsRegExp(atomicTagsOverride[1])
1181
+ : noAtomicTagsRegExp;
1182
+ }
1183
+
1184
+ var innerDiff = diffCore(before.innerHtml, after.innerHtml, className, dataPrefix);
1185
+ } finally {
1186
+ innerDiffDepth--;
1187
+ atomicTagsRegExp = outerAtomicTagsRegExp;
1188
+ }
1189
+ return after.openingTag + innerDiff + after.closingTag;
1190
+ }
1191
+
912
1192
  /**
913
1193
  * OPS.equal/insert/delete/replace are functions that render an operation into
914
1194
  * HTML content.
@@ -931,10 +1211,23 @@
931
1211
  */
932
1212
  var OPS = {
933
1213
  '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
- }, '');
1214
+ // Tokens in an equal operation pair up one to one between before and after. Equal
1215
+ // keys do not guarantee equal strings (e.g. atomic tokens matched by
1216
+ // data-htmldiff-id): elements that opted in via data-htmldiff-inner-diff get a
1217
+ // recursive diff of their content, everything else renders the after version.
1218
+ var result = '';
1219
+ for (var i = 0; op.startInAfter + i <= op.endInAfter; i++){
1220
+ var afterToken = afterTokens[op.startInAfter + i];
1221
+ var beforeToken = beforeTokens[op.startInBefore + i];
1222
+ if (beforeToken && beforeToken.string !== afterToken.string &&
1223
+ isInnerDiffToken(afterToken.string)){
1224
+ result += renderInnerDiff(
1225
+ beforeToken.string, afterToken.string, dataPrefix, className);
1226
+ } else {
1227
+ result += afterToken.string;
1228
+ }
1229
+ }
1230
+ return result;
938
1231
  },
939
1232
  'insert': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
940
1233
  var tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
@@ -1012,13 +1305,24 @@
1012
1305
  * @return {string} The combined HTML content with differences wrapped in <ins> and <del> tags.
1013
1306
  */
1014
1307
  function diff(before, after, className, dataPrefix, atomicTags){
1015
- if (before === after) return before;
1016
-
1017
1308
  // Enable user provided atomic tag list.
1018
- atomicTags ?
1019
- (atomicTagsRegExp = new RegExp('^<(' + atomicTags.replace(/\s*/g, '').replace(/,/g, '|') + ')\b'))
1309
+ atomicTags ?
1310
+ (atomicTagsRegExp = buildAtomicTagsRegExp(atomicTags))
1020
1311
  : (atomicTagsRegExp = defaultAtomicTagsRegExp);
1021
1312
 
1313
+ return diffCore(before, after, className, dataPrefix);
1314
+ }
1315
+
1316
+ /**
1317
+ * Runs the diff pipeline with whatever atomic tags regular expression is currently active.
1318
+ * Used by the diff function after resolving the atomicTags parameter and by
1319
+ * renderInnerDiff, which sets the regular expression itself.
1320
+ *
1321
+ * @see function diff for the parameter and return value descriptions.
1322
+ */
1323
+ function diffCore(before, after, className, dataPrefix){
1324
+ if (before === after) return before;
1325
+
1022
1326
  before = htmlToTokens(before);
1023
1327
  after = htmlToTokens(after);
1024
1328
  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.6.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')
@@ -155,11 +155,95 @@ describe('htmlToTokens', function(){
155
155
  expect(cut(atomic)).eql(tokenize([atomic]));
156
156
  });
157
157
 
158
+ it('should key a wrapper by its own data-htmldiff-id, not a nested child one', function(){
159
+ var atomic = '<strong data-htmldiff-id="s1">' +
160
+ '<em data-htmldiff-id="e1">C</em></strong>';
161
+ expect(cut(atomic)[0].key).eql('s1');
162
+ });
163
+
164
+ it('should not key an unkeyed atomic tag by a nested child data-htmldiff-id', function(){
165
+ var atomic = '<a><em data-htmldiff-id="e1">C</em></a>';
166
+ expect(cut(atomic)[0].key).eql('<a>');
167
+ });
168
+
158
169
  it('should not bump depth on differently-named tags that share a prefix', function(){
159
170
  // a tag must not be matched by an atomic tag named "a" appearing as <article>.
160
171
  var atomic = '<a href="x"><article>hi</article></a>';
161
172
  expect(cut(atomic)).eql(tokenize([atomic]));
162
173
  });
163
174
  });
175
+
176
+ describe('tags sharing a prefix with atomic tag names', function(){
177
+ it('should not treat <abbr> as the atomic tag a', function(){
178
+ expect(cut('<abbr>x</abbr> tail')).eql(
179
+ tokenize(['<abbr>', 'x', '</abbr>', ' ', 'tail']));
180
+ });
181
+
182
+ it('should not treat <article> as the atomic tag a', function(){
183
+ expect(cut('<article>hi</article>')).eql(
184
+ tokenize(['<article>', 'hi', '</article>']));
185
+ });
186
+ });
187
+
188
+ describe('self-closing atomic tags', function(){
189
+ it('should end a self-closing data-htmldiff-id tag without swallowing trailing content', function(){
190
+ expect(cut('<div data-htmldiff-id="s"/>x old')).eql(
191
+ tokenize(['<div data-htmldiff-id="s"/>', 'x', ' ', 'old']));
192
+ });
193
+
194
+ it('should end a self-closing name-based atomic tag without swallowing trailing content', function(){
195
+ expect(cut('<svg/>tail')).eql(tokenize(['<svg/>', 'tail']));
196
+ });
197
+ });
198
+
199
+ describe('quoted attribute values containing tag delimiters', function(){
200
+ it('should not end a tag on ">" inside a double-quoted attribute value', function(){
201
+ expect(cut('<p class="a>b">text</p>')).eql(
202
+ tokenize(['<p class="a>b">', 'text', '</p>']));
203
+ });
204
+
205
+ it('should not end a tag on ">" inside a single-quoted attribute value', function(){
206
+ expect(cut("<p class='a>b'>x</p>")).eql(
207
+ tokenize(["<p class='a>b'>", 'x', '</p>']));
208
+ });
209
+
210
+ it('should not end a void atomic tag on ">" inside an attribute value', function(){
211
+ expect(cut('<img data-htmldiff-id="1" alt="a > b"> tail')).eql(
212
+ tokenize(['<img data-htmldiff-id="1" alt="a > b">', ' ', 'tail']));
213
+ });
214
+
215
+ it('should not treat "/>" inside an attribute value as self-closing', function(){
216
+ var atomic = '<span data-htmldiff-id="1" data-x="y/>z">c</span>';
217
+ expect(cut(atomic)).eql(tokenize([atomic]));
218
+ });
219
+
220
+ it('should keep an atomic tag with ">" in an attribute as one token', function(){
221
+ expect(cut('<div data-htmldiff-id="1" title="a > b">x</div> tail')).eql(
222
+ tokenize(['<div data-htmldiff-id="1" title="a > b">x</div>',
223
+ ' ', 'tail']));
224
+ });
225
+
226
+ it('should not treat apostrophes in atomic text content as quotes', function(){
227
+ expect(cut("<div data-htmldiff-id='1'>it's ok</div> tail")).eql(
228
+ tokenize(["<div data-htmldiff-id='1'>it's ok</div>", ' ', 'tail']));
229
+ });
230
+
231
+ it('should not treat apostrophes in comments inside atomic tags as quotes', function(){
232
+ expect(cut("<math><mi>x<!-- don't --></mi></math> tail")).eql(
233
+ tokenize(["<math><mi>x<!-- don't --></mi></math>", ' ', 'tail']));
234
+ });
235
+ });
236
+
237
+ describe('void atomic tags', function(){
238
+ it('should end a void data-htmldiff-id tag written without a slash', function(){
239
+ expect(cut('<img data-htmldiff-id="1" src="a.jpg"> tail')).eql(
240
+ tokenize(['<img data-htmldiff-id="1" src="a.jpg">', ' ', 'tail']));
241
+ });
242
+
243
+ it('should end a void data-htmldiff-id br tag without swallowing trailing content', function(){
244
+ expect(cut('<br data-htmldiff-id="x">y')).eql(
245
+ tokenize(['<br data-htmldiff-id="x">', 'y']));
246
+ });
247
+ });
164
248
  });
165
249
  });
@@ -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)')