@matrixreq/htmldiff 1.0.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/LICENSE +22 -0
- package/README.md +147 -0
- package/htmldiff-cli.js +125 -0
- package/js/htmldiff.d.ts +17 -0
- package/js/htmldiff.js +1005 -0
- package/package.json +60 -0
- package/sample/after.html +18 -0
- package/sample/after.png +0 -0
- package/sample/before.html +18 -0
- package/sample/before.png +0 -0
- package/test/calculate_operations.spec.js +278 -0
- package/test/config.js +2 -0
- package/test/diff.spec.js +210 -0
- package/test/edge_cases.spec.js +31 -0
- package/test/find_matching_blocks.spec.js +168 -0
- package/test/from_port_source.spec.js +30 -0
- package/test/html_to_tokens.spec.js +128 -0
- package/test/mocha.opts +3 -0
- package/test/module.spec.js +11 -0
- package/test/pain_games.spec.js +18 -0
- package/test/render_operations.spec.js +177 -0
package/js/htmldiff.js
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmldiff.js is a library that compares HTML content. It creates a diff between two
|
|
3
|
+
* HTML documents by combining the two documents and wrapping the differences with
|
|
4
|
+
* <ins> and <del> tags. Here is a high-level overview of how the diff works.
|
|
5
|
+
*
|
|
6
|
+
* 1. Tokenize the before and after HTML with htmlToTokens.
|
|
7
|
+
* 2. Generate a list of operations that convert the before list of tokens to the after
|
|
8
|
+
* list of tokens with calculateOperations, which does the following:
|
|
9
|
+
* a. Find all the matching blocks of tokens between the before and after lists of
|
|
10
|
+
* tokens with findMatchingBlocks. This is done by finding the single longest
|
|
11
|
+
* matching block with findMatch, then iteratively finding the next longest
|
|
12
|
+
* matching blocks that precede and follow the longest matching block.
|
|
13
|
+
* b. Determine insertions, deletions, and replacements from the matching blocks.
|
|
14
|
+
* This is done in calculateOperations.
|
|
15
|
+
* 3. Render the list of operations by wrapping tokens with <ins> and <del> tags where
|
|
16
|
+
* appropriate with renderOperations.
|
|
17
|
+
*
|
|
18
|
+
* Example usage:
|
|
19
|
+
*
|
|
20
|
+
* var htmldiff = require('htmldiff.js');
|
|
21
|
+
*
|
|
22
|
+
* htmldiff('<p>this is some text</p>', '<p>this is some more text</p>')
|
|
23
|
+
* == '<p>this is some <ins>more </ins>text</p>'
|
|
24
|
+
*
|
|
25
|
+
* htmldiff('<p>this is some text</p>', '<p>this is some more text</p>', 'diff-class')
|
|
26
|
+
* == '<p>this is some <ins class="diff-class">more </ins>text</p>'
|
|
27
|
+
*/
|
|
28
|
+
(function(){
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
function isEndOfTag(char){
|
|
32
|
+
return char === '>';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isStartOfTag(char){
|
|
36
|
+
return char === '<';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isWhitespace(char){
|
|
40
|
+
return /^\s+$/.test(char);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Determines if the given token is a tag.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} token The token in question.
|
|
47
|
+
*
|
|
48
|
+
* @return {boolean|string} False if the token is not a tag, or the tag name otherwise.
|
|
49
|
+
*/
|
|
50
|
+
function isTag(token){
|
|
51
|
+
var match = token.match(/^\s*<([^!>][^>]*)>\s*$/);
|
|
52
|
+
return !!match && match[1].trim().split(' ')[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isntTag(token){
|
|
56
|
+
return !isTag(token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isStartofHTMLComment(word){
|
|
60
|
+
return /^<!--/.test(word);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isEndOfHTMLComment(word){
|
|
64
|
+
return /--\>$/.test(word);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Regular expression to check atomic tags.
|
|
69
|
+
* @see function diff.
|
|
70
|
+
*/
|
|
71
|
+
var atomicTagsRegExp;
|
|
72
|
+
// 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*\/?[>"']))*.)["']?/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks if the current word is the beginning of an atomic tag. An atomic tag is one whose
|
|
78
|
+
* child nodes should not be compared - the entire tag should be treated as one token. This
|
|
79
|
+
* is useful for tags where it does not make sense to insert <ins> and <del> tags.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} word The characters of the current token read so far.
|
|
82
|
+
*
|
|
83
|
+
* @return {string|null} The name of the atomic tag if the word will be an atomic tag,
|
|
84
|
+
* null otherwise
|
|
85
|
+
*/
|
|
86
|
+
function isStartOfAtomicTag(word){
|
|
87
|
+
var result = atomicTagsRegExp.exec(word) || dataHtmlDiffIdRegExp.exec(word);
|
|
88
|
+
return result && result[1];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks if the current word is the end of an atomic tag (i.e. it has all the characters,
|
|
93
|
+
* except for the end bracket of the closing tag, such as '<iframe></iframe').
|
|
94
|
+
*
|
|
95
|
+
* @param {string} word The characters of the current token read so far.
|
|
96
|
+
* @param {string} tag The ending tag to look for.
|
|
97
|
+
*
|
|
98
|
+
* @return {boolean} True if the word is now a complete token (including the end tag),
|
|
99
|
+
* false otherwise.
|
|
100
|
+
*/
|
|
101
|
+
function isEndOfAtomicTag(word, tag){
|
|
102
|
+
return word.substring(word.length - tag.length - 2) === ('</' + tag);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Checks if a tag is a void tag.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} token The token to check.
|
|
109
|
+
*
|
|
110
|
+
* @return {boolean} True if the token is a void tag, false otherwise.
|
|
111
|
+
*/
|
|
112
|
+
function isVoidTag(token){
|
|
113
|
+
return /^\s*<[^>]+\/>\s*$/.test(token);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checks if a token can be wrapped inside a tag.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} token The token to check.
|
|
120
|
+
*
|
|
121
|
+
* @return {boolean} True if the token can be wrapped inside a tag, false otherwise.
|
|
122
|
+
*/
|
|
123
|
+
function isWrappable(token){
|
|
124
|
+
var is_img = /^<img[\s>]/.test(token);
|
|
125
|
+
return is_img|| isntTag(token) || isStartOfAtomicTag(token) || isVoidTag(token);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Creates a token that holds a string and key representation. The key is used for diffing
|
|
130
|
+
* comparisons and the string is used to recompose the document after the diff is complete.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} currentWord The section of the document to create a token for.
|
|
133
|
+
*
|
|
134
|
+
* @return {Object} A token object with a string and key property.
|
|
135
|
+
*/
|
|
136
|
+
function createToken(currentWord){
|
|
137
|
+
return {
|
|
138
|
+
string: currentWord,
|
|
139
|
+
key: getKeyForToken(currentWord)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A Match stores the information of a matching block. A matching block is a list of
|
|
145
|
+
* consecutive tokens that appear in both the before and after lists of tokens.
|
|
146
|
+
*
|
|
147
|
+
* @param {number} startInBefore The index of the first token in the list of before tokens.
|
|
148
|
+
* @param {number} startInAfter The index of the first token in the list of after tokens.
|
|
149
|
+
* @param {number} length The number of consecutive matching tokens in this block.
|
|
150
|
+
* @param {Segment} segment The segment where the match was found.
|
|
151
|
+
*/
|
|
152
|
+
function Match(startInBefore, startInAfter, length, segment){
|
|
153
|
+
this.segment = segment;
|
|
154
|
+
this.length = length;
|
|
155
|
+
|
|
156
|
+
this.startInBefore = startInBefore + segment.beforeIndex;
|
|
157
|
+
this.startInAfter = startInAfter + segment.afterIndex;
|
|
158
|
+
this.endInBefore = this.startInBefore + this.length - 1;
|
|
159
|
+
this.endInAfter = this.startInAfter + this.length - 1;
|
|
160
|
+
|
|
161
|
+
this.segmentStartInBefore = startInBefore;
|
|
162
|
+
this.segmentStartInAfter = startInAfter;
|
|
163
|
+
this.segmentEndInBefore = (this.segmentStartInBefore + this.length) - 1;
|
|
164
|
+
this.segmentEndInAfter = (this.segmentStartInAfter + this.length) - 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Tokenizes a string of HTML.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} html The string to tokenize.
|
|
171
|
+
*
|
|
172
|
+
* @return {Array.<string>} The list of tokens.
|
|
173
|
+
*/
|
|
174
|
+
function htmlToTokens(html){
|
|
175
|
+
var mode = 'char';
|
|
176
|
+
var currentWord = '';
|
|
177
|
+
var currentAtomicTag = '';
|
|
178
|
+
var words = [];
|
|
179
|
+
for (var i = 0; i < html.length; i++){
|
|
180
|
+
var char = html[i];
|
|
181
|
+
switch (mode){
|
|
182
|
+
case 'tag':
|
|
183
|
+
var atomicTag = isStartOfAtomicTag(currentWord);
|
|
184
|
+
if (atomicTag){
|
|
185
|
+
mode = 'atomic_tag';
|
|
186
|
+
currentAtomicTag = atomicTag;
|
|
187
|
+
currentWord += char;
|
|
188
|
+
} else if (isStartofHTMLComment(currentWord)){
|
|
189
|
+
mode = 'html_comment';
|
|
190
|
+
currentWord += char;
|
|
191
|
+
} else if (isEndOfTag(char)){
|
|
192
|
+
currentWord += '>';
|
|
193
|
+
words.push(createToken(currentWord));
|
|
194
|
+
currentWord = '';
|
|
195
|
+
if (isWhitespace(char)){
|
|
196
|
+
mode = 'whitespace';
|
|
197
|
+
} else {
|
|
198
|
+
mode = 'char';
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
currentWord += char;
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
case 'atomic_tag':
|
|
205
|
+
if (isEndOfTag(char) && isEndOfAtomicTag(currentWord, currentAtomicTag)){
|
|
206
|
+
currentWord += '>';
|
|
207
|
+
words.push(createToken(currentWord));
|
|
208
|
+
currentWord = '';
|
|
209
|
+
currentAtomicTag = '';
|
|
210
|
+
mode = 'char';
|
|
211
|
+
} else {
|
|
212
|
+
currentWord += char;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case 'html_comment':
|
|
216
|
+
currentWord += char;
|
|
217
|
+
if (isEndOfHTMLComment(currentWord)){
|
|
218
|
+
currentWord = '';
|
|
219
|
+
mode = 'char';
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
case 'char':
|
|
223
|
+
if (isStartOfTag(char)){
|
|
224
|
+
if (currentWord){
|
|
225
|
+
words.push(createToken(currentWord));
|
|
226
|
+
}
|
|
227
|
+
currentWord = '<';
|
|
228
|
+
mode = 'tag';
|
|
229
|
+
} else if (/\s/.test(char)){
|
|
230
|
+
if (currentWord){
|
|
231
|
+
words.push(createToken(currentWord));
|
|
232
|
+
}
|
|
233
|
+
currentWord = char;
|
|
234
|
+
mode = 'whitespace';
|
|
235
|
+
} else if (/[\w\d\#@]/.test(char)){
|
|
236
|
+
currentWord += char;
|
|
237
|
+
} else if (/&/.test(char)){
|
|
238
|
+
if (currentWord){
|
|
239
|
+
words.push(createToken(currentWord));
|
|
240
|
+
}
|
|
241
|
+
currentWord = char;
|
|
242
|
+
} else {
|
|
243
|
+
currentWord += char;
|
|
244
|
+
words.push(createToken(currentWord));
|
|
245
|
+
currentWord = '';
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
case 'whitespace':
|
|
249
|
+
if (isStartOfTag(char)){
|
|
250
|
+
if (currentWord){
|
|
251
|
+
words.push(createToken(currentWord));
|
|
252
|
+
}
|
|
253
|
+
currentWord = '<';
|
|
254
|
+
mode = 'tag';
|
|
255
|
+
} else if (isWhitespace(char)){
|
|
256
|
+
currentWord += char;
|
|
257
|
+
} else {
|
|
258
|
+
if (currentWord){
|
|
259
|
+
words.push(createToken(currentWord));
|
|
260
|
+
}
|
|
261
|
+
currentWord = char;
|
|
262
|
+
mode = 'char';
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
default:
|
|
266
|
+
throw new Error('Unknown mode ' + mode);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (currentWord){
|
|
270
|
+
words.push(createToken(currentWord));
|
|
271
|
+
}
|
|
272
|
+
return words;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Creates a key that should be used to match tokens. This is useful, for example, if we want
|
|
277
|
+
* to consider two open tag tokens as equal, even if they don't have the same attributes. We
|
|
278
|
+
* use a key instead of overwriting the token because we may want to render the original string
|
|
279
|
+
* without losing the attributes.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} token The token to create the key for.
|
|
282
|
+
*
|
|
283
|
+
* @return {string} The identifying key that should be used to match before and after tokens.
|
|
284
|
+
*/
|
|
285
|
+
function getKeyForToken(token){
|
|
286
|
+
// If the token is an image element, grab it's src attribute to include in the key.
|
|
287
|
+
var img = /^<img.*src=['"]([^"']*)['"].*>$/.exec(token);
|
|
288
|
+
if (img) {
|
|
289
|
+
return '<img src="' + img[1] + '">';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// If the token is an a element, grab it's data attribute to include in the key.
|
|
293
|
+
var a = /^<a.*href=['"]([^"']*)['"]/.exec(token);
|
|
294
|
+
if (a) {
|
|
295
|
+
return '<a href="' + a[1] + '"></a>';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// If the token is an object element, grab it's data attribute to include in the key.
|
|
299
|
+
var object = /^<object.*data=['"]([^"']*)['"]/.exec(token);
|
|
300
|
+
if (object) {
|
|
301
|
+
return '<object src="' + object[1] + '"></object>';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// If it's a video, math or svg element, the entire token should be compared except the
|
|
305
|
+
// data-uuid.
|
|
306
|
+
if(/^<(svg|math|video)[\s>]/.test(token)) {
|
|
307
|
+
var uuid = token.indexOf('data-uuid="');
|
|
308
|
+
if (uuid !== -1) {
|
|
309
|
+
var start = token.slice(0, uuid);
|
|
310
|
+
var end = token.slice(uuid + 44);
|
|
311
|
+
return start + end;
|
|
312
|
+
} else {
|
|
313
|
+
return token;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// If the token is an iframe element, grab it's src attribute to include in it's key.
|
|
318
|
+
var iframe = /^<iframe.*src=['"]([^"']*)['"].*>/.exec(token);
|
|
319
|
+
if (iframe) {
|
|
320
|
+
return '<iframe src="' + iframe[1] + '"></iframe>';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// if the token has data-htmldiff-uuid use it as a key
|
|
324
|
+
const uuidTag = dataHtmlDiffIdRegExp.exec(token);
|
|
325
|
+
if (uuidTag) {
|
|
326
|
+
return uuidTag[2];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// If the token is any other element, just grab the tag name.
|
|
330
|
+
var tagName = /<([^\s>]+)[\s>]/.exec(token);
|
|
331
|
+
if (tagName){
|
|
332
|
+
return '<' + (tagName[1].toLowerCase()) + '>';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Otherwise, the token is text, collapse the whitespace.
|
|
336
|
+
if (token) {
|
|
337
|
+
return token.replace(/(\s+| | )/g, ' ');
|
|
338
|
+
}
|
|
339
|
+
return token;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Creates a map from token key to an array of indices of locations of the matching token in
|
|
344
|
+
* the list of all tokens.
|
|
345
|
+
*
|
|
346
|
+
* @param {Array.<string>} tokens The list of tokens to be mapped.
|
|
347
|
+
*
|
|
348
|
+
* @return {Object} A mapping that can be used to search for tokens.
|
|
349
|
+
*/
|
|
350
|
+
function createMap(tokens){
|
|
351
|
+
return tokens.reduce(function(map, token, index){
|
|
352
|
+
if (map[token.key]){
|
|
353
|
+
map[token.key].push(index);
|
|
354
|
+
} else {
|
|
355
|
+
map[token.key] = [index];
|
|
356
|
+
}
|
|
357
|
+
return map;
|
|
358
|
+
}, Object.create(null));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Compares two match objects to determine if the second match object comes before or after the
|
|
363
|
+
* first match object. Returns -1 if the m2 should come before m1. Returns 1 if m1 should come
|
|
364
|
+
* before m2. If the two matches criss-cross each other, a null is returned.
|
|
365
|
+
*
|
|
366
|
+
* @param {Match} m1 The first match object to compare.
|
|
367
|
+
* @param {Match} m2 The second match object to compare.
|
|
368
|
+
*
|
|
369
|
+
* @return {number} Returns -1 if the m2 should come before m1. Returns 1 if m1 should come
|
|
370
|
+
* before m2. If the two matches criss-cross each other, 0 is returned.
|
|
371
|
+
*/
|
|
372
|
+
function compareMatches(m1, m2){
|
|
373
|
+
if (m2.endInBefore < m1.startInBefore && m2.endInAfter < m1.startInAfter){
|
|
374
|
+
return -1;
|
|
375
|
+
} else if (m2.startInBefore > m1.endInBefore && m2.startInAfter > m1.endInAfter){
|
|
376
|
+
return 1;
|
|
377
|
+
} else {
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A constructor for a binary search tree used to keep match objects in the proper order as
|
|
384
|
+
* they're found.
|
|
385
|
+
*
|
|
386
|
+
* @constructor
|
|
387
|
+
*/
|
|
388
|
+
function MatchBinarySearchTree(){
|
|
389
|
+
this._root = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
MatchBinarySearchTree.prototype = {
|
|
393
|
+
/**
|
|
394
|
+
* Adds matches to the binary search tree.
|
|
395
|
+
*
|
|
396
|
+
* @param {Match} value The match to add to the binary search tree.
|
|
397
|
+
*/
|
|
398
|
+
add: function (value){
|
|
399
|
+
// Create the node to hold the match value.
|
|
400
|
+
var node = {
|
|
401
|
+
value: value,
|
|
402
|
+
left: null,
|
|
403
|
+
right: null
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
var current = this._root;
|
|
407
|
+
if(current){
|
|
408
|
+
while (true){
|
|
409
|
+
// Determine if the match value should go to the left or right of the current
|
|
410
|
+
// node.
|
|
411
|
+
var position = compareMatches(current.value, value);
|
|
412
|
+
if (position === -1){
|
|
413
|
+
// The position of the match is to the left of this node.
|
|
414
|
+
if (current.left){
|
|
415
|
+
current = current.left;
|
|
416
|
+
} else {
|
|
417
|
+
current.left = node;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
} else if (position === 1){
|
|
421
|
+
// The position of the match is to the right of this node.
|
|
422
|
+
if (current.right){
|
|
423
|
+
current = current.right;
|
|
424
|
+
} else {
|
|
425
|
+
current.right = node;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
// If 0 was returned from compareMatches, that means the node cannot
|
|
430
|
+
// be inserted because it overlaps an existing node.
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
// If no nodes exist in the tree, make this the root node.
|
|
436
|
+
this._root = node;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Converts the binary search tree into an array using an in-order traversal.
|
|
442
|
+
*
|
|
443
|
+
* @return {Array.<Match>} An array containing the matches in the binary search tree.
|
|
444
|
+
*/
|
|
445
|
+
toArray: function(){
|
|
446
|
+
function inOrder(node, nodes){
|
|
447
|
+
if (node){
|
|
448
|
+
inOrder(node.left, nodes);
|
|
449
|
+
nodes.push(node.value);
|
|
450
|
+
inOrder(node.right, nodes);
|
|
451
|
+
}
|
|
452
|
+
return nodes;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return inOrder(this._root, []);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Finds and returns the best match between the before and after arrays contained in the segment
|
|
462
|
+
* provided.
|
|
463
|
+
*
|
|
464
|
+
* @param {Segment} segment The segment in which to look for a match.
|
|
465
|
+
*
|
|
466
|
+
* @return {Match} The best match.
|
|
467
|
+
*/
|
|
468
|
+
function findBestMatch(segment){
|
|
469
|
+
var beforeTokens = segment.beforeTokens;
|
|
470
|
+
var afterMap = segment.afterMap;
|
|
471
|
+
var lastSpace = null;
|
|
472
|
+
var bestMatch = null;
|
|
473
|
+
|
|
474
|
+
// Iterate through the entirety of the beforeTokens to find the best match.
|
|
475
|
+
for (var beforeIndex = 0; beforeIndex < beforeTokens.length; beforeIndex++){
|
|
476
|
+
var lookBehind = false;
|
|
477
|
+
|
|
478
|
+
// If the current best match is longer than the remaining tokens, we can bail because we
|
|
479
|
+
// won't find a better match.
|
|
480
|
+
var remainingTokens = beforeTokens.length - beforeIndex;
|
|
481
|
+
if (bestMatch && remainingTokens < bestMatch.length){
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// If the current token is whitespace, make a note of it and move on. Trying to start a
|
|
486
|
+
// set of matches with whitespace is not efficient because it's too prevelant in most
|
|
487
|
+
// documents. Instead, if the next token yields a match, we'll see if the whitespace can
|
|
488
|
+
// be included in that match.
|
|
489
|
+
var beforeToken = beforeTokens[beforeIndex];
|
|
490
|
+
if (beforeToken.key === ' '){
|
|
491
|
+
lastSpace = beforeIndex;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Check to see if we just skipped a space, if so, we'll ask getFullMatch to look behind
|
|
496
|
+
// by one token to see if it can include the whitespace.
|
|
497
|
+
if (lastSpace === beforeIndex - 1){
|
|
498
|
+
lookBehind = true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// If the current token is not found in the afterTokens, it won't match and we can move
|
|
502
|
+
// on.
|
|
503
|
+
var afterTokenLocations = afterMap[beforeToken.key];
|
|
504
|
+
if(!afterTokenLocations){
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// For each instance of the current token in afterTokens, let's see how big of a match
|
|
509
|
+
// we can build.
|
|
510
|
+
afterTokenLocations.forEach(function(afterIndex){
|
|
511
|
+
// getFullMatch will see how far the current token match will go in both
|
|
512
|
+
// beforeTokens and afterTokens.
|
|
513
|
+
var bestMatchLength = bestMatch ? bestMatch.length : 0;
|
|
514
|
+
var match = getFullMatch(
|
|
515
|
+
segment, beforeIndex, afterIndex, bestMatchLength, lookBehind);
|
|
516
|
+
|
|
517
|
+
// If we got a new best match, we'll save it aside.
|
|
518
|
+
if (match && match.length > bestMatchLength){
|
|
519
|
+
bestMatch = match;
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return bestMatch;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Takes the start of a match, and expands it in the beforeTokens and afterTokens of the
|
|
529
|
+
* current segment as far as it can go.
|
|
530
|
+
*
|
|
531
|
+
* @param {Segment} segment The segment object to search within when expanding the match.
|
|
532
|
+
* @param {number} beforeStart The offset within beforeTokens to start looking.
|
|
533
|
+
* @param {number} afterStart The offset within afterTokens to start looking.
|
|
534
|
+
* @param {number} minLength The minimum length match that must be found.
|
|
535
|
+
* @param {boolean} lookBehind If true, attempt to match a whitespace token just before the
|
|
536
|
+
* beforeStart and afterStart tokens.
|
|
537
|
+
*
|
|
538
|
+
* @return {Match} The full match.
|
|
539
|
+
*/
|
|
540
|
+
function getFullMatch(segment, beforeStart, afterStart, minLength, lookBehind){
|
|
541
|
+
var beforeTokens = segment.beforeTokens;
|
|
542
|
+
var afterTokens = segment.afterTokens;
|
|
543
|
+
|
|
544
|
+
// If we already have a match that goes to the end of the document, no need to keep looking.
|
|
545
|
+
var minBeforeIndex = beforeStart + minLength;
|
|
546
|
+
var minAfterIndex = afterStart + minLength;
|
|
547
|
+
if(minBeforeIndex >= beforeTokens.length || minAfterIndex >= afterTokens.length){
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// If a minLength was provided, we can do a quick check to see if the tokens after that
|
|
552
|
+
// length match. If not, we won't be beating the previous best match, and we can bail out
|
|
553
|
+
// early.
|
|
554
|
+
if (minLength){
|
|
555
|
+
var nextBeforeWord = beforeTokens[minBeforeIndex].key;
|
|
556
|
+
var nextAfterWord = afterTokens[minAfterIndex].key;
|
|
557
|
+
if (nextBeforeWord !== nextAfterWord){
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Extend the current match as far foward as it can go, without overflowing beforeTokens or
|
|
563
|
+
// afterTokens.
|
|
564
|
+
var searching = true;
|
|
565
|
+
var currentLength = 1;
|
|
566
|
+
var beforeIndex = beforeStart + currentLength;
|
|
567
|
+
var afterIndex = afterStart + currentLength;
|
|
568
|
+
|
|
569
|
+
while (searching && beforeIndex < beforeTokens.length && afterIndex < afterTokens.length){
|
|
570
|
+
var beforeWord = beforeTokens[beforeIndex].key;
|
|
571
|
+
var afterWord = afterTokens[afterIndex].key;
|
|
572
|
+
if (beforeWord === afterWord){
|
|
573
|
+
currentLength++;
|
|
574
|
+
beforeIndex = beforeStart + currentLength;
|
|
575
|
+
afterIndex = afterStart + currentLength;
|
|
576
|
+
} else {
|
|
577
|
+
searching = false;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// If we've been asked to look behind, it's because both beforeTokens and afterTokens may
|
|
582
|
+
// have a whitespace token just behind the current match that was previously ignored. If so,
|
|
583
|
+
// we'll expand the current match to include it.
|
|
584
|
+
if (lookBehind && beforeStart > 0 && afterStart > 0){
|
|
585
|
+
var prevBeforeKey = beforeTokens[beforeStart - 1].key;
|
|
586
|
+
var prevAfterKey = afterTokens[afterStart - 1].key;
|
|
587
|
+
if (prevBeforeKey === ' ' && prevAfterKey === ' '){
|
|
588
|
+
beforeStart--;
|
|
589
|
+
afterStart--;
|
|
590
|
+
currentLength++;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return new Match(beforeStart, afterStart, currentLength, segment);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Creates segment objects from the original document that can be used to restrict the area that
|
|
599
|
+
* findBestMatch and it's helper functions search to increase performance.
|
|
600
|
+
*
|
|
601
|
+
* @param {Array.<Token>} beforeTokens Tokens from the before document.
|
|
602
|
+
* @param {Array.<Token>} afterTokens Tokens from the after document.
|
|
603
|
+
* @param {number} beforeIndex The index within the before document where this segment begins.
|
|
604
|
+
* @param {number} afterIndex The index within the after document where this segment behinds.
|
|
605
|
+
*
|
|
606
|
+
* @return {Segment} The segment object.
|
|
607
|
+
*/
|
|
608
|
+
function createSegment(beforeTokens, afterTokens, beforeIndex, afterIndex){
|
|
609
|
+
return {
|
|
610
|
+
beforeTokens: beforeTokens,
|
|
611
|
+
afterTokens: afterTokens,
|
|
612
|
+
beforeMap: createMap(beforeTokens),
|
|
613
|
+
afterMap: createMap(afterTokens),
|
|
614
|
+
beforeIndex: beforeIndex,
|
|
615
|
+
afterIndex: afterIndex
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Finds all the matching blocks within the given segment in the before and after lists of
|
|
621
|
+
* tokens.
|
|
622
|
+
*
|
|
623
|
+
* @param {Segment} The segment that should be searched for matching blocks.
|
|
624
|
+
*
|
|
625
|
+
* @return {Array.<Match>} The list of matching blocks in this range.
|
|
626
|
+
*/
|
|
627
|
+
function findMatchingBlocks(segment){
|
|
628
|
+
// Create a binary search tree to hold the matches we find in order.
|
|
629
|
+
var matches = new MatchBinarySearchTree();
|
|
630
|
+
var match;
|
|
631
|
+
var segments = [segment];
|
|
632
|
+
|
|
633
|
+
// Each time the best match is found in a segment, zero, one or two new segments may be
|
|
634
|
+
// created from the parts of the original segment not included in the match. We will
|
|
635
|
+
// continue to iterate until all segments have been processed.
|
|
636
|
+
while(segments.length){
|
|
637
|
+
segment = segments.pop();
|
|
638
|
+
match = findBestMatch(segment);
|
|
639
|
+
|
|
640
|
+
if (match && match.length){
|
|
641
|
+
// If there's an unmatched area at the start of the segment, create a new segment
|
|
642
|
+
// from that area and throw it into the segments array to get processed.
|
|
643
|
+
if (match.segmentStartInBefore > 0 && match.segmentStartInAfter > 0){
|
|
644
|
+
var leftBeforeTokens = segment.beforeTokens.slice(
|
|
645
|
+
0, match.segmentStartInBefore);
|
|
646
|
+
var leftAfterTokens = segment.afterTokens.slice(0, match.segmentStartInAfter);
|
|
647
|
+
|
|
648
|
+
segments.push(createSegment(leftBeforeTokens, leftAfterTokens,
|
|
649
|
+
segment.beforeIndex, segment.afterIndex));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// If there's an unmatched area at the end of the segment, create a new segment from that
|
|
653
|
+
// area and throw it into the segments array to get processed.
|
|
654
|
+
var rightBeforeTokens = segment.beforeTokens.slice(match.segmentEndInBefore + 1);
|
|
655
|
+
var rightAfterTokens = segment.afterTokens.slice(match.segmentEndInAfter + 1);
|
|
656
|
+
var rightBeforeIndex = segment.beforeIndex + match.segmentEndInBefore + 1;
|
|
657
|
+
var rightAfterIndex = segment.afterIndex + match.segmentEndInAfter + 1;
|
|
658
|
+
|
|
659
|
+
if (rightBeforeTokens.length && rightAfterTokens.length){
|
|
660
|
+
segments.push(createSegment(rightBeforeTokens, rightAfterTokens,
|
|
661
|
+
rightBeforeIndex, rightAfterIndex));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
matches.add(match);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return matches.toArray();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Gets a list of operations required to transform the before list of tokens into the
|
|
673
|
+
* after list of tokens. An operation describes whether a particular list of consecutive
|
|
674
|
+
* tokens are equal, replaced, inserted, or deleted.
|
|
675
|
+
*
|
|
676
|
+
* @param {Array.<string>} beforeTokens The before list of tokens.
|
|
677
|
+
* @param {Array.<string>} afterTokens The after list of tokens.
|
|
678
|
+
*
|
|
679
|
+
* @return {Array.<Object>} The list of operations to transform the before list of
|
|
680
|
+
* tokens into the after list of tokens, where each operation has the following
|
|
681
|
+
* keys:
|
|
682
|
+
* - {string} action One of {'replace', 'insert', 'delete', 'equal'}.
|
|
683
|
+
* - {number} startInBefore The beginning of the range in the list of before tokens.
|
|
684
|
+
* - {number} endInBefore The end of the range in the list of before tokens.
|
|
685
|
+
* - {number} startInAfter The beginning of the range in the list of after tokens.
|
|
686
|
+
* - {number} endInAfter The end of the range in the list of after tokens.
|
|
687
|
+
*/
|
|
688
|
+
function calculateOperations(beforeTokens, afterTokens){
|
|
689
|
+
if (!beforeTokens) throw new Error('Missing beforeTokens');
|
|
690
|
+
if (!afterTokens) throw new Error('Missing afterTokens');
|
|
691
|
+
|
|
692
|
+
var positionInBefore = 0;
|
|
693
|
+
var positionInAfter = 0;
|
|
694
|
+
var operations = [];
|
|
695
|
+
var segment = createSegment(beforeTokens, afterTokens, 0, 0);
|
|
696
|
+
var matches = findMatchingBlocks(segment);
|
|
697
|
+
matches.push(new Match(beforeTokens.length, afterTokens.length, 0, segment));
|
|
698
|
+
|
|
699
|
+
for (var index = 0; index < matches.length; index++){
|
|
700
|
+
var match = matches[index];
|
|
701
|
+
var actionUpToMatchPositions = 'none';
|
|
702
|
+
if (positionInBefore === match.startInBefore){
|
|
703
|
+
if (positionInAfter !== match.startInAfter){
|
|
704
|
+
actionUpToMatchPositions = 'insert';
|
|
705
|
+
}
|
|
706
|
+
} else {
|
|
707
|
+
actionUpToMatchPositions = 'delete';
|
|
708
|
+
if (positionInAfter !== match.startInAfter){
|
|
709
|
+
actionUpToMatchPositions = 'replace';
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (actionUpToMatchPositions !== 'none'){
|
|
713
|
+
operations.push({
|
|
714
|
+
action: actionUpToMatchPositions,
|
|
715
|
+
startInBefore: positionInBefore,
|
|
716
|
+
endInBefore: (actionUpToMatchPositions !== 'insert' ?
|
|
717
|
+
match.startInBefore - 1 : null),
|
|
718
|
+
startInAfter: positionInAfter,
|
|
719
|
+
endInAfter: (actionUpToMatchPositions !== 'delete' ?
|
|
720
|
+
match.startInAfter - 1 : null)
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
if (match.length !== 0){
|
|
724
|
+
operations.push({
|
|
725
|
+
action: 'equal',
|
|
726
|
+
startInBefore: match.startInBefore,
|
|
727
|
+
endInBefore: match.endInBefore,
|
|
728
|
+
startInAfter: match.startInAfter,
|
|
729
|
+
endInAfter: match.endInAfter
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
positionInBefore = match.endInBefore + 1;
|
|
733
|
+
positionInAfter = match.endInAfter + 1;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
var postProcessed = [];
|
|
737
|
+
var lastOp = {action: 'none'};
|
|
738
|
+
|
|
739
|
+
function isSingleWhitespace(op){
|
|
740
|
+
if (op.action !== 'equal'){
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
if (op.endInBefore - op.startInBefore !== 0){
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
return /^\s$/.test(beforeTokens.slice(op.startInBefore, op.endInBefore + 1));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
for (var i = 0; i < operations.length; i++){
|
|
750
|
+
var op = operations[i];
|
|
751
|
+
|
|
752
|
+
if ((isSingleWhitespace(op) && lastOp.action === 'replace') ||
|
|
753
|
+
(op.action === 'replace' && lastOp.action === 'replace')){
|
|
754
|
+
lastOp.endInBefore = op.endInBefore;
|
|
755
|
+
lastOp.endInAfter = op.endInAfter;
|
|
756
|
+
} else {
|
|
757
|
+
postProcessed.push(op);
|
|
758
|
+
lastOp = op;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return postProcessed;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* A TokenWrapper provides a utility for grouping segments of tokens based on whether they're
|
|
766
|
+
* wrappable or not. A tag is considered wrappable if it is closed within the given set of
|
|
767
|
+
* tokens. For example, given the following tokens:
|
|
768
|
+
*
|
|
769
|
+
* ['</b>', 'this', ' ', 'is', ' ', 'a', ' ', '<b>', 'test', '</b>', '!']
|
|
770
|
+
*
|
|
771
|
+
* The first '</b>' is not considered wrappable since the tag is not fully contained within the
|
|
772
|
+
* array of tokens. The '<b>', 'test', and '</b>' would be a part of the same wrappable segment
|
|
773
|
+
* since the entire bold tag is within the set of tokens.
|
|
774
|
+
*
|
|
775
|
+
* TokenWrapper has a method 'combine' which allows walking over the segments to wrap them in
|
|
776
|
+
* tags.
|
|
777
|
+
*/
|
|
778
|
+
function TokenWrapper(tokens){
|
|
779
|
+
this.tokens = tokens;
|
|
780
|
+
this.notes = tokens.reduce(function(data, token, index){
|
|
781
|
+
data.notes.push({
|
|
782
|
+
isWrappable: isWrappable(token),
|
|
783
|
+
insertedTag: false
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
var tag = !isVoidTag(token) && isTag(token);
|
|
787
|
+
var lastEntry = data.tagStack[data.tagStack.length - 1];
|
|
788
|
+
if (tag){
|
|
789
|
+
if (lastEntry && '/' + lastEntry.tag === tag){
|
|
790
|
+
data.notes[lastEntry.position].insertedTag = true;
|
|
791
|
+
data.tagStack.pop();
|
|
792
|
+
} else {
|
|
793
|
+
data.tagStack.push({
|
|
794
|
+
tag: tag,
|
|
795
|
+
position: index
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return data;
|
|
800
|
+
}, {notes: [], tagStack: []}).notes;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Wraps the contained tokens in tags based on output given by a map function. Each segment of
|
|
805
|
+
* tokens will be visited. A segment is a continuous run of either all wrappable
|
|
806
|
+
* tokens or unwrappable tokens. The given map function will be called with each segment of
|
|
807
|
+
* tokens and the resulting strings will be combined to form the wrapped HTML.
|
|
808
|
+
*
|
|
809
|
+
* @param {function(boolean, Array.<string>)} mapFn A function called with an array of tokens
|
|
810
|
+
* and whether those tokens are wrappable or not. The result should be a string.
|
|
811
|
+
*/
|
|
812
|
+
TokenWrapper.prototype.combine = function(mapFn, tagFn){
|
|
813
|
+
var notes = this.notes;
|
|
814
|
+
var tokens = this.tokens.slice();
|
|
815
|
+
var segments = tokens.reduce(function(data, token, index){
|
|
816
|
+
if (notes[index].insertedTag){
|
|
817
|
+
tokens[index] = tagFn(tokens[index]);
|
|
818
|
+
}
|
|
819
|
+
if (data.status === null){
|
|
820
|
+
data.status = notes[index].isWrappable;
|
|
821
|
+
}
|
|
822
|
+
var status = notes[index].isWrappable;
|
|
823
|
+
if (status !== data.status){
|
|
824
|
+
data.list.push({
|
|
825
|
+
isWrappable: data.status,
|
|
826
|
+
tokens: tokens.slice(data.lastIndex, index)
|
|
827
|
+
});
|
|
828
|
+
data.lastIndex = index;
|
|
829
|
+
data.status = status;
|
|
830
|
+
}
|
|
831
|
+
if (index === tokens.length - 1){
|
|
832
|
+
data.list.push({
|
|
833
|
+
isWrappable: data.status,
|
|
834
|
+
tokens: tokens.slice(data.lastIndex, index + 1)
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return data;
|
|
838
|
+
}, {list: [], status: null, lastIndex: 0}).list;
|
|
839
|
+
|
|
840
|
+
return segments.map(mapFn).join('');
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Wraps and concatenates a list of tokens with a tag. Does not wrap tag tokens,
|
|
845
|
+
* unless they are wrappable (i.e. void and atomic tags).
|
|
846
|
+
*
|
|
847
|
+
* @param {sting} tag The tag name of the wrapper tags.
|
|
848
|
+
* @param {Array.<string>} content The list of tokens to wrap.
|
|
849
|
+
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
850
|
+
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
851
|
+
*/
|
|
852
|
+
function wrap(tag, content, opIndex, dataPrefix, className){
|
|
853
|
+
var wrapper = new TokenWrapper(content);
|
|
854
|
+
dataPrefix = dataPrefix ? dataPrefix + '-' : '';
|
|
855
|
+
var attrs = ' data-' + dataPrefix + 'operation-index="' + opIndex + '"';
|
|
856
|
+
if (className){
|
|
857
|
+
attrs += ' class="' + className + '"';
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return wrapper.combine(function(segment){
|
|
861
|
+
if (segment.isWrappable){
|
|
862
|
+
var val = segment.tokens.join('');
|
|
863
|
+
if (val.trim()){
|
|
864
|
+
return '<' + tag + attrs + '>' + val + '</' + tag + '>';
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
return segment.tokens.join('');
|
|
868
|
+
}
|
|
869
|
+
return '';
|
|
870
|
+
}, function(openingTag){
|
|
871
|
+
var dataAttrs = ' data-diff-node="' + tag + '"';
|
|
872
|
+
dataAttrs += ' data-' + dataPrefix + 'operation-index="' + opIndex + '"';
|
|
873
|
+
|
|
874
|
+
return openingTag.replace(/>\s*$/, dataAttrs + '$&');
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* OPS.equal/insert/delete/replace are functions that render an operation into
|
|
880
|
+
* HTML content.
|
|
881
|
+
*
|
|
882
|
+
* @param {Object} op The operation that applies to a prticular list of tokens. Has the
|
|
883
|
+
* following keys:
|
|
884
|
+
* - {string} action One of ['replace', 'insert', 'delete', 'equal'].
|
|
885
|
+
* - {number} startInBefore The beginning of the range in the list of before tokens.
|
|
886
|
+
* - {number} endInBefore The end of the range in the list of before tokens.
|
|
887
|
+
* - {number} startInAfter The beginning of the range in the list of after tokens.
|
|
888
|
+
* - {number} endInAfter The end of the range in the list of after tokens.
|
|
889
|
+
* @param {Array.<string>} beforeTokens The before list of tokens.
|
|
890
|
+
* @param {Array.<string>} afterTokens The after list of tokens.
|
|
891
|
+
* @param {number} opIndex The index into the list of operations that identifies the change to
|
|
892
|
+
* be rendered. This is used to mark wrapped HTML as part of the same operation.
|
|
893
|
+
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
894
|
+
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
895
|
+
*
|
|
896
|
+
* @return {string} The rendering of that operation.
|
|
897
|
+
*/
|
|
898
|
+
var OPS = {
|
|
899
|
+
'equal': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
900
|
+
var tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
|
|
901
|
+
return tokens.reduce(function(prev, curr){
|
|
902
|
+
return prev + curr.string;
|
|
903
|
+
}, '');
|
|
904
|
+
},
|
|
905
|
+
'insert': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
906
|
+
var tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
|
|
907
|
+
var val = tokens.map(function(token){
|
|
908
|
+
return token.string;
|
|
909
|
+
});
|
|
910
|
+
return wrap('ins', val, opIndex, dataPrefix, className);
|
|
911
|
+
},
|
|
912
|
+
'delete': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
913
|
+
var tokens = beforeTokens.slice(op.startInBefore, op.endInBefore + 1);
|
|
914
|
+
var val = tokens.map(function(token){
|
|
915
|
+
return token.string;
|
|
916
|
+
});
|
|
917
|
+
const res = wrap("del", val, opIndex, dataPrefix, className);
|
|
918
|
+
|
|
919
|
+
// handling cases like deleted </p><p>
|
|
920
|
+
if (/^<\/.+?><.+?>$/.exec(res) && !res.includes("del")) {
|
|
921
|
+
return `<del>${val.slice(1, val.length - 1).join("")}</del>`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return res;
|
|
925
|
+
},
|
|
926
|
+
'replace': function(){
|
|
927
|
+
return OPS['delete'].apply(null, arguments) + OPS['insert'].apply(null, arguments);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Renders a list of operations into HTML content. The result is the combined version
|
|
933
|
+
* of the before and after tokens with the differences wrapped in tags.
|
|
934
|
+
*
|
|
935
|
+
* @param {Array.<string>} beforeTokens The before list of tokens.
|
|
936
|
+
* @param {Array.<string>} afterTokens The after list of tokens.
|
|
937
|
+
* @param {Array.<Object>} operations The list of operations to transform the before
|
|
938
|
+
* list of tokens into the after list of tokens, where each operation has the
|
|
939
|
+
* following keys:
|
|
940
|
+
* - {string} action One of {'replace', 'insert', 'delete', 'equal'}.
|
|
941
|
+
* - {number} startInBefore The beginning of the range in the list of before tokens.
|
|
942
|
+
* - {number} endInBefore The end of the range in the list of before tokens.
|
|
943
|
+
* - {number} startInAfter The beginning of the range in the list of after tokens.
|
|
944
|
+
* - {number} endInAfter The end of the range in the list of after tokens.
|
|
945
|
+
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
946
|
+
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
947
|
+
*
|
|
948
|
+
* @return {string} The rendering of the list of operations.
|
|
949
|
+
*/
|
|
950
|
+
function renderOperations(beforeTokens, afterTokens, operations, dataPrefix, className){
|
|
951
|
+
return operations.reduce(function(rendering, op, index){
|
|
952
|
+
return rendering + OPS[op.action](
|
|
953
|
+
op, beforeTokens, afterTokens, index, dataPrefix, className);
|
|
954
|
+
}, '');
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Compares two pieces of HTML content and returns the combined content with differences
|
|
959
|
+
* wrapped in <ins> and <del> tags.
|
|
960
|
+
*
|
|
961
|
+
* @param {string} before The HTML content before the changes.
|
|
962
|
+
* @param {string} after The HTML content after the changes.
|
|
963
|
+
* @param {string} className (Optional) The class attribute to include in <ins> and <del> tags.
|
|
964
|
+
* @param {string} dataPrefix (Optional) The data prefix to use for data attributes. The
|
|
965
|
+
* operation index data attribute will be named `data-${dataPrefix-}operation-index`.
|
|
966
|
+
* @param {string} atomicTags (Optional) Comma separated list of atomic tag names. The
|
|
967
|
+
* list has to be in the form `tag1,tag2,...` e. g. `head,script,style`. If not used,
|
|
968
|
+
* the default list `iframe,object,math,svg,script,video,head,style` will be used.
|
|
969
|
+
*
|
|
970
|
+
* @return {string} The combined HTML content with differences wrapped in <ins> and <del> tags.
|
|
971
|
+
*/
|
|
972
|
+
function diff(before, after, className, dataPrefix, atomicTags){
|
|
973
|
+
if (before === after) return before;
|
|
974
|
+
|
|
975
|
+
// Enable user provided atomic tag list.
|
|
976
|
+
atomicTags ?
|
|
977
|
+
(atomicTagsRegExp = new RegExp('^<(' + atomicTags.replace(/\s*/g, '').replace(/,/g, '|') + ')\b'))
|
|
978
|
+
: (atomicTagsRegExp = defaultAtomicTagsRegExp);
|
|
979
|
+
|
|
980
|
+
before = htmlToTokens(before);
|
|
981
|
+
after = htmlToTokens(after);
|
|
982
|
+
var ops = calculateOperations(before, after);
|
|
983
|
+
return renderOperations(before, after, ops, dataPrefix, className);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
diff.htmlToTokens = htmlToTokens;
|
|
987
|
+
diff.findMatchingBlocks = findMatchingBlocks;
|
|
988
|
+
findMatchingBlocks.findBestMatch = findBestMatch;
|
|
989
|
+
findMatchingBlocks.createMap = createMap;
|
|
990
|
+
findMatchingBlocks.createToken = createToken;
|
|
991
|
+
findMatchingBlocks.createSegment = createSegment;
|
|
992
|
+
findMatchingBlocks.getKeyForToken = getKeyForToken;
|
|
993
|
+
diff.calculateOperations = calculateOperations;
|
|
994
|
+
diff.renderOperations = renderOperations;
|
|
995
|
+
|
|
996
|
+
if (typeof define === 'function'){
|
|
997
|
+
define([], function(){
|
|
998
|
+
return diff;
|
|
999
|
+
});
|
|
1000
|
+
} else if (typeof module !== 'undefined' && module !== null){
|
|
1001
|
+
module.exports = diff;
|
|
1002
|
+
} else {
|
|
1003
|
+
this.htmldiff = diff;
|
|
1004
|
+
}
|
|
1005
|
+
}).call(this);
|