1/** 2 * findAndReplaceDOMText v 0.4.3 3 * @author James Padolsey http://james.padolsey.com 4 * @license http://unlicense.org/UNLICENSE 5 * 6 * Matches the text of a DOM node against a regular expression 7 * and replaces each match (or node-separated portions of the match) 8 * in the specified element. 9 */ 10 (function (root, factory) { 11 if (typeof module === 'object' && module.exports) { 12 // Node/CommonJS 13 module.exports = factory(); 14 } else if (typeof define === 'function' && define.amd) { 15 // AMD. Register as an anonymous module. 16 define(factory); 17 } else { 18 // Browser globals 19 root.findAndReplaceDOMText = factory(); 20 } 21 }(this, function factory() { 22 23 var PORTION_MODE_RETAIN = 'retain'; 24 var PORTION_MODE_FIRST = 'first'; 25 26 var doc = document; 27 var toString = {}.toString; 28 var hasOwn = {}.hasOwnProperty; 29 30 function isArray(a) { 31 return toString.call(a) == '[object Array]'; 32 } 33 34 function escapeRegExp(s) { 35 return String(s).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); 36 } 37 38 function exposed() { 39 // Try deprecated arg signature first: 40 return deprecated.apply(null, arguments) || findAndReplaceDOMText.apply(null, arguments); 41 } 42 43 function deprecated(regex, node, replacement, captureGroup, elFilter) { 44 if ((node && !node.nodeType) && arguments.length <= 2) { 45 return false; 46 } 47 var isReplacementFunction = typeof replacement == 'function'; 48 49 if (isReplacementFunction) { 50 replacement = (function(original) { 51 return function(portion, match) { 52 return original(portion.text, match.startIndex); 53 }; 54 }(replacement)); 55 } 56 57 // Awkward support for deprecated argument signature (<0.4.0) 58 var instance = findAndReplaceDOMText(node, { 59 60 find: regex, 61 62 wrap: isReplacementFunction ? null : replacement, 63 replace: isReplacementFunction ? replacement : '$' + (captureGroup || '&'), 64 65 prepMatch: function(m, mi) { 66 67 // Support captureGroup (a deprecated feature) 68 69 if (!m[0]) throw 'findAndReplaceDOMText cannot handle zero-length matches'; 70 71 if (captureGroup > 0) { 72 var cg = m[captureGroup]; 73 m.index += m[0].indexOf(cg); 74 m[0] = cg; 75 } 76 77 m.endIndex = m.index + m[0].length; 78 m.startIndex = m.index; 79 m.index = mi; 80 81 return m; 82 }, 83 filterElements: elFilter 84 }); 85 86 exposed.revert = function() { 87 return instance.revert(); 88 }; 89 90 return true; 91 } 92 93 /** 94 * findAndReplaceDOMText 95 * 96 * Locates matches and replaces with replacementNode 97 * 98 * @param {Node} node Element or Text node to search within 99 * @param {RegExp} options.find The regular expression to match 100 * @param {String|Element} [options.wrap] A NodeName, or a Node to clone 101 * @param {String|Function} [options.replace='$&'] What to replace each match with 102 * @param {Function} [options.filterElements] A Function to be called to check whether to 103 * process an element. (returning true = process element, 104 * returning false = avoid element) 105 */ 106 function findAndReplaceDOMText(node, options) { 107 return new Finder(node, options); 108 } 109 110 exposed.NON_PROSE_ELEMENTS = { 111 br:1, hr:1, 112 // Media / Source elements: 113 script:1, style:1, img:1, video:1, audio:1, canvas:1, svg:1, map:1, object:1, 114 // Input elements 115 input:1, textarea:1, select:1, option:1, optgroup: 1, button:1 116 }; 117 118 exposed.NON_CONTIGUOUS_PROSE_ELEMENTS = { 119 120 // Elements that will not contain prose or block elements where we don't 121 // want prose to be matches across element borders: 122 123 // Block Elements 124 address:1, article:1, aside:1, blockquote:1, dd:1, div:1, 125 dl:1, fieldset:1, figcaption:1, figure:1, footer:1, form:1, h1:1, h2:1, h3:1, 126 h4:1, h5:1, h6:1, header:1, hgroup:1, hr:1, main:1, nav:1, noscript:1, ol:1, 127 output:1, p:1, pre:1, section:1, ul:1, 128 // Other misc. elements that are not part of continuous inline prose: 129 br:1, li: 1, summary: 1, dt:1, details:1, rp:1, rt:1, rtc:1, 130 // Media / Source elements: 131 script:1, style:1, img:1, video:1, audio:1, canvas:1, svg:1, map:1, object:1, 132 // Input elements 133 input:1, textarea:1, select:1, option:1, optgroup: 1, button:1, 134 // Table related elements: 135 table:1, tbody:1, thead:1, th:1, tr:1, td:1, caption:1, col:1, tfoot:1, colgroup:1 136 137 }; 138 139 exposed.NON_INLINE_PROSE = function(el) { 140 return hasOwn.call(exposed.NON_CONTIGUOUS_PROSE_ELEMENTS, el.nodeName.toLowerCase()); 141 }; 142 143 // Presets accessed via `options.preset` when calling findAndReplaceDOMText(): 144 exposed.PRESETS = { 145 prose: { 146 forceContext: exposed.NON_INLINE_PROSE, 147 filterElements: function(el) { 148 return !hasOwn.call(exposed.NON_PROSE_ELEMENTS, el.nodeName.toLowerCase()); 149 } 150 } 151 }; 152 153 exposed.Finder = Finder; 154 155 /** 156 * Finder -- encapsulates logic to find and replace. 157 */ 158 function Finder(node, options) { 159 160 var preset = options.preset && exposed.PRESETS[options.preset]; 161 162 options.portionMode = options.portionMode || PORTION_MODE_RETAIN; 163 164 if (preset) { 165 for (var i in preset) {
166 if (hasOwn.call(preset, i) && !hasOwn.call(options, i)) { 167 options[i] = preset[i]; 168 } 169 } 170 } 171 172 this.node = node; 173 this.options = options; 174 175 // ENable match-preparation method to be passed as option: 176 this.prepMatch = options.prepMatch || this.prepMatch; 177 178 this.reverts = []; 179 180 this.matches = this.search(); 181 182 if (this.matches.length) { 183 this.processMatches(); 184 } 185 186 } 187 188 Finder.prototype = { 189 190 /** 191 * Searches for all matches that comply with the instance's 'match' option 192 */ 193 search: function() { 194 195 var match; 196 var matchIndex = 0; 197 var offset = 0; 198 var regex = this.options.find; 199 var textAggregation = this.getAggregateText(); 200 var matches = []; 201 var self = this; 202 203 regex = typeof regex === 'string' ? RegExp(escapeRegExp(regex), 'g') : regex; 204 205 matchAggregation(textAggregation); 206 207 function matchAggregation(textAggregation) { 208 for (var i = 0, l = textAggregation.length; i < l; ++i) { 209 210 var text = textAggregation[i]; 211 212 if (typeof text !== 'string') { 213 // Deal with nested contexts: (recursive) 214 matchAggregation(text); 215 continue; 216 } 217 218 if (regex.global) { 219 while (match = regex.exec(text)) { 220 matches.push(self.prepMatch(match, matchIndex++, offset)); 221 } 222 } else { 223 if (match = text.match(regex)) { 224 matches.push(self.prepMatch(match, 0, offset)); 225 } 226 } 227 228 offset += text.length; 229 } 230 } 231 232 return matches; 233 234 }, 235 236 /** 237 * Prepares a single match with useful meta info: 238 */ 239 prepMatch: function(match, matchIndex, characterOffset) { 240 241 if (!match[0]) { 242 throw new Error('findAndReplaceDOMText cannot handle zero-length matches'); 243 } 244 245 match.endIndex = characterOffset + match.index + match[0].length; 246 match.startIndex = characterOffset + match.index; 247 match.index = matchIndex; 248 249 return match; 250 }, 251 252 /** 253 * Gets aggregate text within subject node 254 */ 255 getAggregateText: function() { 256 257 var elementFilter = this.options.filterElements; 258 var forceContext = this.options.forceContext; 259 260 return getText(this.node); 261 262 /** 263 * Gets aggregate text of a node without resorting 264 * to broken innerText/textContent 265 */ 266 function getText(node, txt) { 267 268 if (node.nodeType === 3) { 269 return [node.data]; 270 } 271 272 if (elementFilter && !elementFilter(node)) { 273 return []; 274 } 275 276 var txt = ['']; 277 var i = 0; 278 279 if (node = node.firstChild) do { 280 281 if (node.nodeType === 3) { 282 txt[i] += node.data; 283 continue; 284 } 285 286 var innerText = getText(node); 287 288 if ( 289 forceContext && 290 node.nodeType === 1 && 291 (forceContext === true || forceContext(node)) 292 ) { 293 txt[++i] = innerText; 294 txt[++i] = ''; 295 } else { 296 if (typeof innerText[0] === 'string') { 297 // Bridge nested text-node data so that they're 298 // not considered their own contexts: 299 // I.e. ['some', ['thing']] -> ['something'] 300 txt[i] += innerText.shift(); 301 } 302 if (innerText.length) { 303 txt[++i] = innerText; 304 txt[++i] = ''; 305 } 306 } 307 } while (node = node.nextSibling); 308 309 return txt; 310 311 } 312 313 }, 314 315 /** 316 * Steps through the target node, looking for matches, and 317 * calling replaceFn when a match is found. 318 */ 319 processMatches: function() { 320 321 var matches = this.matches; 322 var node = this.node; 323 var elementFilter = this.options.filterElements; 324 325 var startPortion, 326 endPortion, 327 innerPortions = [], 328 curNode = node, 329 match = matches.shift(), 330 atIndex = 0, // i.e. nodeAtIndex 331 matchIndex = 0, 332 portionIndex = 0, 333 doAvoidNode, 334 nodeStack = [node]; 335 336 out: while (true) { 337 338 if (curNode.nodeType === 3) { 339 340 if (!endPortion && curNode.length + atIndex >= match.endIndex) { 341 342 // We've found the ending 343 endPortion = { 344 node: curNode, 345 index: portionIndex++, 346 text: curNode.data.substring(match.startIndex - atIndex, match.endIndex - atIndex), 347 indexInMatch: atIndex - match.startIndex, 348 indexInNode: match.startIndex - atIndex, // always zero for end-portions 349 endIndexInNode: match.endIndex - atIndex, 350 isEnd: true 351 }; 352 353 } else if (startPortion) { 354 // Intersecting node 355 innerPortions.push({ 356 node: curNode, 357 index: portionIndex++, 358 text: curNode.data, 359 indexInMatch: atIndex - match.startIndex, 360 indexInNode: 0 // always zero for inner-portions 361 }); 362 } 363 364 if (!startPortion && curNode.length + atIndex > match.startIndex) { 365 // We've found the match start 366 startPortion = { 367 node: curNode, 368 index: portionIndex++, 369 indexInMatch: 0, 370 indexInNode: match.startIndex - atIndex, 371 endIndexInNode: match.endIndex - atIndex, 372 text: curNode.data.substring(match.startIndex - atIndex, match.endIndex - atIndex) 373 }; 374 } 375 376 atIndex += curNode.data.length; 377 378 } 379 380 doAvoidNode = curNode.nodeType === 1 && elementFilter && !elementFilter(curNode); 381 382 if (startPortion && endPortion) { 383 384 curNode = this.replaceMatch(match, startPortion, innerPortions, endPortion); 385 386 // processMatches has to return the node that replaced the endNode 387 // and then we step back so we can continue from the end of the 388 // match: 389 390 atIndex -= (endPortion.node.data.length - endPortion.endIndexInNode); 391 392 startPortion = null; 393 endPortion = null; 394 innerPortions = []; 395 match = matches.shift(); 396 portionIndex = 0; 397 matchIndex++; 398 399 if (!match) { 400 break; // no more matches 401 } 402 403 } else if ( 404 !doAvoidNode && 405 (curNode.firstChild || curNode.nextSibling) 406 ) { 407 // Move down or forward: 408 if (curNode.firstChild) { 409 nodeStack.push(curNode); 410 curNode = curNode.firstChild; 411 } else { 412 curNode = curNode.nextSibling; 413 } 414 continue; 415 } 416 417 // Move forward or up: 418 while (true) { 419 if (curNode.nextSibling) { 420 curNode = curNode.nextSibling; 421 break; 422 } 423 curNode = nodeStack.pop(); 424 if (curNode === node) { 425 break out; 426 } 427 } 428 429 } 430 431 }, 432 433 /** 434 * Reverts ... TODO 435 */ 436 revert: function() { 437 // Reversion occurs backwards so as to avoid nodes subsequently 438 // replaced during the matching phase (a forward process): 439 for (var l = this.reverts.length; l--;) { 440 this.reverts[l](); 441 } 442 this.reverts = []; 443 }, 444 445 prepareReplacementString: function(string, portion, match, matchIndex) { 446 var portionMode = this.options.portionMode; 447 if ( 448 portionMode === PORTION_MODE_FIRST && 449 portion.indexInMatch > 0 450 ) { 451 return ''; 452 } 453 string = string.replace(/\$(\d+|&|`|')/g, function($0, t) { 454 var replacement; 455 switch(t) { 456 case '&': 457 replacement = match[0]; 458 break; 459 case '`': 460 replacement = match.input.substring(0, match.startIndex); 461 break; 462 case '\'': 463 replacement = match.input.substring(match.endIndex); 464 break; 465 default: 466 replacement = match[+t]; 467 } 468 return replacement; 469 }); 470
471 if (portionMode === PORTION_MODE_FIRST) { 472 return string; 473 } 474 475 if (portion.isEnd) { 476 return string.substring(portion.indexInMatch); 477 } 478 479 return string.substring(portion.indexInMatch, portion.indexInMatch + portion.text.length); 480 }, 481 482 getPortionReplacementNode: function(portion, match, matchIndex) { 483 484 var replacement = this.options.replace || '$&'; 485 var wrapper = this.options.wrap; 486 487 if (wrapper && wrapper.nodeType) { 488 // Wrapper has been provided as a stencil-node for us to clone: 489 var clone = doc.createElement('div'); 490 clone.innerHTML = wrapper.outerHTML || new XMLSerializer().serializeToString(wrapper); 491 wrapper = clone.firstChild; 492 } 493 494 if (typeof replacement == 'function') { 495 replacement = replacement(portion, match, matchIndex); 496 if (replacement && replacement.nodeType) { 497 return replacement; 498 } 499 return doc.createTextNode(String(replacement)); 500 } 501 502 var el = typeof wrapper == 'string' ? doc.createElement(wrapper) : wrapper; 503 504 replacement = doc.createTextNode( 505 this.prepareReplacementString( 506 replacement, portion, match, matchIndex 507 ) 508 ); 509 510 if (!replacement.data) { 511 return replacement; 512 } 513 514 if (!el) { 515 return replacement; 516 } 517 518 el.appendChild(replacement); 519 520 return el; 521 }, 522 523 replaceMatch: function(match, startPortion, innerPortions, endPortion) { 524 525 var matchStartNode = startPortion.node; 526 var matchEndNode = endPortion.node; 527 528 var preceedingTextNode; 529 var followingTextNode; 530 531 if (matchStartNode === matchEndNode) { 532 533 var node = matchStartNode; 534 535 if (startPortion.indexInNode > 0) { 536 // Add `before` text node (before the match) 537 preceedingTextNode = doc.createTextNode(node.data.substring(0, startPortion.indexInNode)); 538 node.parentNode.insertBefore(preceedingTextNode, node); 539 } 540 541 // Create the replacement node: 542 var newNode = this.getPortionReplacementNode( 543 endPortion, 544 match 545 ); 546 547 node.parentNode.insertBefore(newNode, node); 548 549 if (endPortion.endIndexInNode < node.length) { // ????? 550 // Add `after` text node (after the match) 551 followingTextNode = doc.createTextNode(node.data.substring(endPortion.endIndexInNode)); 552 node.parentNode.insertBefore(followingTextNode, node); 553 } 554 555 node.parentNode.removeChild(node); 556 557 this.reverts.push(function() { 558 if (preceedingTextNode === newNode.previousSibling) { 559 preceedingTextNode.parentNode.removeChild(preceedingTextNode); 560 } 561 if (followingTextNode === newNode.nextSibling) { 562 followingTextNode.parentNode.removeChild(followingTextNode); 563 } 564 newNode.parentNode.replaceChild(node, newNode); 565 }); 566 567 return newNode; 568 569 } else { 570 // Replace matchStartNode -> [innerMatchNodes...] -> matchEndNode (in that order) 571 572 573 preceedingTextNode = doc.createTextNode( 574 matchStartNode.data.substring(0, startPortion.indexInNode) 575 ); 576 577 followingTextNode = doc.createTextNode( 578 matchEndNode.data.substring(endPortion.endIndexInNode) 579 ); 580 581 var firstNode = this.getPortionReplacementNode( 582 startPortion, 583 match 584 ); 585 586 var innerNodes = []; 587 588 for (var i = 0, l = innerPortions.length; i < l; ++i) { 589 var portion = innerPortions[i]; 590 var innerNode = this.getPortionReplacementNode( 591 portion, 592 match 593 ); 594 portion.node.parentNode.replaceChild(innerNode, portion.node); 595 this.reverts.push((function(portion, innerNode) { 596 return function() { 597 innerNode.parentNode.replaceChild(portion.node, innerNode); 598 }; 599 }(portion, innerNode))); 600 innerNodes.push(innerNode); 601 } 602 603 var lastNode = this.getPortionReplacementNode( 604 endPortion, 605 match 606 ); 607 608 matchStartNode.parentNode.insertBefore(preceedingTextNode, matchStartNode); 609 matchStartNode.parentNode.insertBefore(firstNode, matchStartNode); 610 matchStartNode.parentNode.removeChild(matchStartNode); 611 612 matchEndNode.parentNode.insertBefore(lastNode, matchEndNode); 613 matchEndNode.parentNode.insertBefore(followingTextNode, matchEndNode); 614 matchEndNode.parentNode.removeChild(matchEndNode); 615 616 this.reverts.push(function() { 617 preceedingTextNode.parentNode.removeChild(preceedingTextNode); 618 firstNode.parentNode.replaceChild(matchStartNode, firstNode); 619 followingTextNode.parentNode.removeChild(followingTextNode); 620 lastNode.parentNode.replaceChild(matchEndNode, lastNode); 621 }); 622 623 return lastNode; 624 } 625 } 626 627 }; 628 629 return exposed; 630 631}));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.