1/** 2 * CHANGE LOG 3 * 4 * 16/06/2026 ââ¬â Excluding "blogPost" from article selector in ensureDividers() 5 * to prevent removing h2/h3 tags. 6 * 7 * 02/09/2026 ââ¬â sanitizeHeadings(): Fixed false-positive removal of dividers 8 * containing only plain text. $divider.children() ignores text nodes, so 9 * dividers with direct text content were treated as empty and deleted. 10 * Now checks $divider.contents() for non-whitespace text nodes. 11 * 12 * 02/09/2026 ââ¬â finalizeSanitization(): Expanded the "empty divider" removal 13 * filter to also check for h1-h6, ul, ol, and direct text content. 14 * Previously only checked for <p>, causing dividers with headings, lists, 15 * or plain text to be incorrectly removed. 16 * 17 * 02/09/2026 ââ¬â Added absorbOrphanedLists(): Moves orphaned <ul>/<ol> that 18 * are direct children of <article> (outside any .divider) into the nearest 19 * preceding .divider. Fixes privacy-policy-style CMS content where lists 20 * appear as siblings of dividers rather than inside them. 21 * 22 * 02/09/2026 ââ¬â Added splitMultiHeadingDividers(): Splits dividers that 23 * contain multiple <h2> headings into separate dividers, one per section. 24 * The CMS often produces a single giant <div class="divider"> wrapping 25 * all page content with multiple h2s. NOTE: has a known bug where the 26 * first h2 gets detached from the original divider (line 481 uses 27 * firstH2Pos instead of secondH2Pos). Heading-only dividers left by 28 * the split are removed by sanitizeHeadings ââ¬â should run 29 * fixDanglingHeadings before sanitizeHeadings to merge them first. 30 * 31 * 02/09/2026 ââ¬â Added mergeHeadingOnlyDividers(): Merges dividers that 32 * contain only a heading with no meaningful content (<p>, <ul>, <ol>, 33 * <img>, text) into the next sibling divider. 34 * 35 * 02/09/2026 ââ¬â Added fixDanglingHeadings(): Moves h2/h3 headings that 36 * appear at the very end of a divider (followed only by <br>, <hr>, 37 * or whitespace) into the next divider so they aren't left as orphaned 38 * headings with no content beneath them. 39 * 40 * 02/09/2026 ââ¬â organizeHeadings(): Added early return when a bare 41 * heading's immediate next sibling is already a .divider ââ¬â prepends 42 * the heading directly instead of searching for an "empty" divider 43 * (which incorrectly matched dividers with content). Also tightened 44 * the empty-divider fallback check to require no p/ul/ol/img content. 45 * 46 * 16/09/2026 ââ¬â splitMultiHeadingDividers(): Fixed first-section content loss. 47 * The cleanup loop was detaching from the first h2 (h2Positions[0]), which 48 * silently dropped the first h2 and everything between it and the second h2. 49 * Now detaches from h2Positions[1] so the first h2 and its section content 50 * stay in the original divider. Also replaced the confusing inline warning 51 * comment with an accurate one, and removed this from KNOWN ISSUES. 52 * 53 * 16/09/2026 ââ¬â Replaced the flat stage call list with a declarative `stages` 54 * array + reportStage() error logger. Each stage failure is now recorded 55 * to localStorage under key "sanitizerLog" (last 50 entries) so broken 56 * pages can be diagnosed after the fact ââ¬â press F12 on a broken page and 57 * paste: JSON.parse(localStorage.getItem('sanitizerLog') || '[]') 58 * The array also documents execution order in one place. ORDER MATTERS ââ¬â 59 * see the comment above the array before reordering. 60 * 61 * KNOWN ISSUES: 62 * - sanitizeHeadings still removes h3-h6 when h2 is present, 63 * which can destroy FAQ question headings in single-section dividers. 64 * - normalizeDivs converts <strong>/<em> inside <li> to <p>, creating 65 * invalid HTML (<p> inside <li>). Browser restructures the DOM and 66 * text after the inline tag can be orphaned. 67 */ 68$(document).ready(function () { 69 // Cache frequently accessed DOM elements to reduce redundant queries 70 const $article = $("article"); 71 const $contentWrapper = $(".content-wrapper"); 72 const $borderMain = $(".border-main"); 73 74 /** 75 * ===================================================================== 76 * SANITIZER ERROR LOG (localStorage-based) 77 * ===================================================================== 78 * WHY THIS EXISTS: 79 * When a stage fails on a page, 80 * nothing visible happens ââ¬â the catch just logs to the browser 81 * console where nobody looks. This records failures to localStorage 82 * so they can be diagnosed AFTER the fact. 83 * 84 * HOW TO READ IT (tell support / remember for yourself): 85 * Open the broken page, press F12, go to Console, paste: 86 * 87 * JSON.parse(localStorage.getItem('sanitizerLog') || '[]') 88 * 89 * Each entry shows: t = timestamp, p = page path, s = failed stage 90 * name, m = error message (truncated to 200 chars). 91 * 92 * LIMITATIONS (known and accepted): 93 * - Per-browser, per-device only. Cleared if the user wipes data. 94 * - We only find out when someone actually reports a page. 95 * - Capped at last 50 entries to stay far below the ~5MB quota. 96 * ===================================================================== 97 */ 98 const SANITIZER_LOG_KEY = "sanitizerLog"; 99 const SANITIZER_LOG_MAX = 50; 100 101 const reportStage = (stage, error) => { 102 try { 103 const entry = { 104 t: new Date().toISOString(), // when 105 p: location.pathname, // where (path only ââ¬â keeps it small, no query tokens) 106 s: stage, // which stage failed 107 m: String(error && error.message ? error.message : error).slice(0, 200), // why 108 }; 109 const log = JSON.parse(localStorage.getItem(SANITIZER_LOG_KEY) || "[]"); 110 log.push(entry); 111 localStorage.setItem( 112 SANITIZER_LOG_KEY, 113 JSON.stringify(log.slice(-SANITIZER_LOG_MAX)), 114 ); 115 } catch (_) { 116 // Never let the logger itself break the page (storage disabled, 117 // private mode with storage full). Silent by design. 118 } 119 }; 120 121 // Smooth scroll to top 122 $(".scrollTop").on("click", function (e) { 123 e.preventDefault(); 124 125 $("html, body").animate( 126 { 127 scrollTop: 0, 128 }, 129 800, 130 ); // Adjust the duration as needed 131 }); 132 133 // Mobile dropdown menu toggle 134 $(".mobile-dropdown-toggle").on("click", function (e) { 135 e.preventDefault(); 136 e.stopPropagation(); 137 138 const dropdown = $(this).next(".mobile-services-menu"); 139 140 if (dropdown.hasClass("show")) {
141 dropdown.removeClass("show").slideUp(); 142 } else { 143 dropdown.addClass("show").slideDown(); 144 } 145 }); 146 147 // If clicking outside the dropdown should close it, add a click listener on the document 148 $(document).on("click", function () { 149 $(".mobile-services-menu").slideUp().removeClass("show"); 150 }); 151 152 // Prevent the document click listener from closing the dropdown if clicking inside it 153 $(".mobile-services-menu, .mobile-dropdown-toggle").on("click", function (e) { 154 e.stopPropagation(); 155 }); 156 157 /** 158 * Moves buttons from content to after the last paragraph 159 * Ensures consistent button placement across all content sections 160 */ 161 $(".content").each(function () { 162 try { 163 var $content = $(this); 164 var $link = $content.find("a.btn"); 165 if ($link.length) { 166 $link.insertAfter($content.find("p").last()); 167 } 168 } catch (error) { 169 console.error("Error moving buttons:", error); 170 } 171 }); 172 173 /** 174 * Handles DOM manipulation and content formatting for article pages. 175 * Performs the following operations: 176 * - Normalizes div elements into paragraphs 177 * - Manages image placement within paragraphs 178 * - Organizes headings within divider sections 179 * - Applies consistent image styling 180 * - Centers iframes in the content 181 * 182 * Executes when the DOM is fully loaded. 183 * Requires jQuery. 184 */ 185 186 // Remove falsy divs and replace it with paragraph to properly format 187 const paraTitles = $("h2, h3, em, strong"); 188 let lastParagraphWithoutImage = null; 189 190 /** 191 * Repairs cases where CMS content contains stray closing </div> tags 192 * that prematurely close the grid-flip container. 193 * Detects orphaned elements after #about section and moves them back inside. 194 */ 195 const repairWrapperIntegrity = () => { 196 try { 197 const $wrapper = $(".wrapper .grid-flip").first(); 198 if (!$wrapper.length) return; 199 200 // If footer is outside .wrapper, the nesting broke 201 if ($("footer").length && $("footer").closest(".wrapper").length === 0) { 202 $wrapper 203 .nextAll() 204 .not("script, style, link") 205 .each(function () { 206 $wrapper.append($(this)); 207 }); 208 } 209 } catch (error) { 210 console.error("Error in repairWrapperIntegrity:", error); 211 reportStage("repairWrapperIntegrity", error); // record for post-hoc diagnosis 212 } 213 }; 214 215 /** 216 * Moves content from outside the main content wrapper into the article 217 * Ensures all relevant content is properly contained within the article element 218 */ 219 const initialOuterCheck = () => { 220 try { 221 $borderMain 222 .children() 223 .not($contentWrapper) 224 .each(function () { 225 const $element = $(this); 226 $element.appendTo($article); 227 }); 228 229 $borderMain.siblings().each(function () { 230 const $element = $(this); 231 $element.appendTo($article); 232 }); 233 } catch (error) { 234 console.error("Error in initialOuterCheck:", error); 235 reportStage("initialOuterCheck", error); // record for post-hoc diagnosis 236 } 237 }; 238 239 /** 240 * Removes unnecessary nesting of em and strong tags that contain block elements 241 * Improves HTML structure by preventing invalid nesting patterns 242 */ 243 const cleanNestedTags = () => { 244 try { 245 $("em, strong").each(function () { 246 const $element = $(this); 247 248 if ($element.find("img, p").length > 0) { 249 $element.replaceWith($element.contents()); 250 } 251 }); 252 } catch (error) { 253 console.error("Error in cleanNestedTags:", error); 254 reportStage("cleanNestedTags", error); // record for post-hoc diagnosis 255 } 256 }; 257 258 /** 259 * Ensures bare <div> elements (without a class) that are direct children of <article> 260 * are assigned the "divider" class so they participate in the full formatting pipeline. 261 * 262 * CMS content sometimes produces <div> elements without the "divider" class that contain 263 * meaningful content (text, images, inline semantic elements). Without the "divider" class, 264 * these elements are invisible to downstream functions such as addingFloatClass(), 265 * organizeImagesInDividers(), organizeHeadings(), headingsFollowedByParagraph(), 266 * moveImagesToParagraphsFromDivider(), and finalizeSanitization(), all of which rely on 267 * the ".divider" selector to locate and process content sections. 268 * 269 * This function bridges that gap by inspecting each classless direct-child <div> of <article>
270 * and promoting it to a ".divider" if it contains either visible text or structural elements 271 * (img, p, strong, em). Empty or purely structural divs without meaningful content are left 272 * untouched to avoid interfering with other layout concerns. 273 * 274 * Execution order: Runs after cleanNestedTags() (to ensure nested tag issues are resolved first) 275 * and before normalizeDivs() (so newly classified dividers can be properly normalized). 276 * 277 * @function ensureDividers 278 * @returns {void} 279 * @example 280 * // Before: 281 * // <article> 282 * // <div class="divider">First section...</div> 283 * // <div>Second section with no class...</div> ââ  missed by formatting pipeline 284 * // </article> 285 * // 286 * // After: 287 * // <article> 288 * // <div class="divider">First section...</div> 289 * // <div class="divider">Second section with no class...</div> ââ  now processed 290 * // </article> 291 */ 292 const ensureDividers = () => { 293 const $article = $("article:not(.blogPost):not(.blogList)"); 294 try { 295 $article.children("div:not([class])").each(function () { 296 const $div = $(this); 297 const hasText = $div.text().trim().length > 0; 298 const hasContent = $div.find("img, p, strong, em").length > 0; 299 const formElement = 300 $div.find("input, textarea, select, button").length > 0; // Skip if it contains form elements to avoid layout interference 301 const hasHeadings = $div.find("h1, h2, h3, h4, h5, h6").length > 0; 302 const hasParagraphs = $div.find("p").length > 0; 303 304 if (formElement) return; // Skip form elements 305 306 if (hasHeadings && !hasParagraphs) { 307 // Heading-only div: unwrap so organizeHeadings() can handle the headings properly 308 // instead of letting sanitizeHeadings() destroy them downstream 309 $div.children().insertBefore($div); 310 $div.remove(); 311 } else if (hasText || hasContent) { 312 $div.addClass("divider"); 313 } 314 }); 315 } catch (error) { 316 console.error("Error in ensureDividers:", error); 317 reportStage("ensureDividers", error); // record for post-hoc diagnosis 318 } 319 }; 320 321 /** 322 * Converts non-semantic divs to paragraphs and handles nested elements 323 * Ensures proper semantic structure and consistent content formatting 324 */ 325 const normalizeDivs = () => { 326 try { 327 $(".divider").each(function () { 328 const $divider = $(this); 329 330 $divider.find("div:not([class]), em, strong").each(function () { 331 const $element = $(this); 332 if ($element.is("em, strong") && $element.closest("li").length) return; 333 const content = $element.html().trim(); 334 335 // Skip empty elements 336 if (!content) return; 337 338 // Handle nested elements 339 if ($element.find("div, em, strong").length) { 340 // Recursively process nested elements first 341 $element.children().unwrap(); 342 return; 343 } 344 345 // Create new paragraph with original content 346 const $newP = $("<p>").html(content); 347 348 // Preserve any important inline styles 349 if ($element.is("strong")) { 350 $newP.css({ "font-weight": "bold", display: "inline" }); 351 } else if ($element.is("em")) { 352 $newP.css({ "font-style": "italic", display: "inline" }); 353 } 354 355 $element.replaceWith($newP); 356 }); 357 358 $divider.children(".divider").each(function () { 359 const $mainArticle = $("article:not(.recent-blog)").first(); 360 if ($mainArticle.length) { 361 $(this).detach().appendTo($mainArticle); 362 } 363 }); 364 365 if ( 366 $divider.find("img").length > 0 && 367 $divider.find("h2, h3, h4, p, .divider").length === 0 368 ) { 369 const $image = $divider.find("img").first(); 370 const $nearestPWithoutImage = $divider 371 .nextAll("p") 372 .filter((_, p) => $(p).find("img").length === 0) 373 .first(); 374 375 if ($nearestPWithoutImage.length) { 376 $nearestPWithoutImage.prepend($image); 377 } else { 378 $image.remove(); 379 } 380 381 if ($divider.is(":empty")) { 382 $divider.remove(); 383 } 384 } 385 }); 386 } catch (error) { 387 console.error("Error in normalizeDivs:", error); 388 reportStage("normalizeDivs", error); // record for post-hoc diagnosis 389 } 390 }; 391 392 /** 393 * Handles images that are direct children of the article element 394 * Moves them into appropriate paragraphs for consistent styling 395 */ 396 const strayImages = () => { 397 try { 398 $article.children("img").each(function () { 399 const $img = $(this); 400 const $nearestParagraph = $img 401 .nextAll("p") 402 .filter(function () { 403 return $(this).find("img").length === 0; 404 }) 405 .first(); 406 407 if ($nearestParagraph.length) { 408 $img.prependTo($nearestParagraph); 409 } else { 410 const $newParagraph = $("<p></p>"); 411 $img.after($newParagraph); 412 $img.prependTo($newParagraph); 413 } 414 }); 415 } catch (error) { 416 console.error("Error in strayImages:", error); 417 reportStage("strayImages", error); // record for post-hoc diagnosis 418 } 419 }; 420 421 /** 422 * Organizes images within dividers to ensure proper placement 423 * Moves images from empty paragraphs to the first text paragraph in each divider 424 */ 425 const organizeImagesInDividers = () => { 426 try { 427 $(".divider").each(function () { 428 const $divider = $(this); 429 430 $divider.find("p").each(function () { 431 const $paragraph = $(this); 432 const $images = $paragraph.find("img"); 433 434 // Check if this <p> contains only images and whitespace 435 const onlyImages = 436 $paragraph.contents().filter(function () { 437 return ( 438 (this.nodeType === 3 && !/\S/.test(this.nodeValue)) || 439 (this.nodeType === 1 && this.tagName === "IMG") 440 ); 441 }).length === $paragraph.contents().length; 442 443 if ($images.length > 0 && onlyImages) { 444 // Find the next <p> with text and no images 445 const $nextTextParagraph = $paragraph 446 .nextAll("p") 447 .filter(function () { 448 const $p = $(this); 449 const hasText = 450 $p.clone().children("img, br").remove().end().text().trim() 451 .length > 0;
452 const hasImages = $p.find("img").length > 0; 453 return hasText && !hasImages; 454 }) 455 .first(); 456 457 if ($nextTextParagraph.length) { 458 // Move all images to the start of the next text paragraph 459 $images.prependTo($nextTextParagraph); 460 $paragraph.remove(); 461 } 462 } 463 }); 464 }); 465 } catch (error) { 466 console.error("Error in organizeImagesInDividers:", error); 467 reportStage("organizeImagesInDividers", error); // record for post-hoc diagnosis 468 } 469 }; 470 471 /** 472 * Organizes headings within the article by grouping them with related content in dividers 473 * Finds headings not already in dividers, collects their related content, 474 * and either places them in existing empty dividers or creates new ones. 475 * Also handles special cases where dividers have headers but no paragraphs. 476 */ 477 const organizeHeadings = () => { 478 try { 479 if (window.location.pathname !== "/blog") { 480 $article.children("h2, h3, h4,h5, p").each(function () { 481 const $heading = $(this); 482 if ($heading.closest(".divider").length) return; // Skip if already in a divider 483 484 // Find related content (before modifying the DOM) 485 const $relatedContent = $heading 486 .nextUntil("h2, h3, h4, h5, .divider") 487 .filter("p, ul, ol"); 488 489 // Check for existing empty divider immediately after the heading's original position 490 const $emptyDivider = $heading 491 .nextAll(".divider") 492 .filter((_, divider) => { 493 return ( 494 $(divider).children("h2, h3, h4, h5").length === 0 && 495 $heading.nextUntil(divider, "p, ul, ol").length === 0 496 ); 497 }) 498 .first(); 499 500 if ($emptyDivider.length) { 501 // Move heading and content into the existing empty divider 502 $emptyDivider.prepend($heading, $relatedContent); 503 } else { 504 // Create new divider and insert before heading 505 const $newDivider = $('<div class="divider"></div>'); 506 $heading.before($newDivider); 507 $newDivider.append($heading, $relatedContent); 508 } 509 }); 510 511 // After organizing headings, check for dividers with headers but no paragraphs 512 $article.find(".divider").each(function () { 513 const $divider = $(this); 514 515 if ( 516 $divider.find("h2, h3, h4").length > 0 && 517 $divider.find("p").length === 0 518 ) { 519 // Find paragraphs that immediately follow this divider 520 const $followingParagraphs = $divider 521 .nextUntil("h2, h3, h4, .divider") 522 .filter("p"); 523 524 if ($followingParagraphs.length) { 525 // Move these paragraphs into this divider since they logically belong to it 526 $divider.append($followingParagraphs); 527 } 528 } 529 }); 530 } 531 } catch (error) { 532 console.error("Error in organizeHeadings:", error); 533 reportStage("organizeHeadings", error); // record for post-hoc diagnosis 534 } 535 }; 536 537/** 538 * Absorbs orphaned <ul> and <ol> that are direct children of <article> 539 * but sit outside any .divider. Moves them into the nearest preceding
540 * .divider so they aren't left as unstyled bare lists. 541 */ 542const absorbOrphanedLists = () => { 543 try { 544 $article.children("ul, ol").each(function () { 545 const $list = $(this); 546 const $prevDivider = $list.prevAll(".divider").first(); 547 if ($prevDivider.length) { 548 $prevDivider.append($list); 549 } 550 }); 551 } catch (error) { 552 console.error("Error in absorbOrphanedLists:", error); 553 reportStage("absorbOrphanedLists", error); // record for post-hoc diagnosis 554 } 555}; 556 557 558/** 559 * Splits dividers that contain multiple <h2> headings into separate 560 * dividers, one per section. The CMS often produces a single giant 561 * <div class="divider"> wrapping all content with multiple h2s inside. 562 * This function splits at each h2 boundary so that sanitizeHeadings() 563 * and organizeHeadings() can process each section independently. 564 * 565 * Runs after normalizeDivs() and before sanitizeHeadings(). 566 */ 567const splitMultiHeadingDividers = () => { 568 try { 569 $(".divider").each(function () { 570 const $divider = $(this); 571 const $h2s = $divider.children("h2"); 572 573 if ($h2s.length <= 1) return; // Already single-section 574 575 // Snapshot all child nodes (elements + text) so indices don't shift 576 const allChildren = Array.from(this.childNodes); 577 578 // Find positions of h2 elements 579 const h2Positions = []; 580 allChildren.forEach((node, i) => { 581 if (node.nodeType === 1 && node.tagName === "H2") { 582 h2Positions.push(i); 583 } 584 }); 585 586 // Create new dividers for each h2 after the first, 587 // each containing that h2 and everything until the next h2 588 for (let i = h2Positions.length - 1; i >= 1; i--) { 589 const start = h2Positions[i]; 590 const end = 591 i < h2Positions.length - 1 592 ? h2Positions[i + 1] 593 : allChildren.length; 594 const $newDivider = $('<div class="divider"></div>'); 595 for (let j = start; j < end; j++) { 596 $newDivider.append(allChildren[j]); 597 } 598 $divider.after($newDivider); 599 } 600 601 // The new dividers above consumed everything from the SECOND h2 602 // (h2Positions[1]) onward, so the parentNode guard below skips those. 603 // This loop only detaches any leftover nodes between the first and 604 // second h2 ââ¬â which should be none, but guard against edge cases. 605 // Do NOT start this loop at h2Positions[0]: that would detach the 606 // first h2 and its section content into nothing (old bug, see 607 // change log 16/09/2026). 608 const secondH2Pos = h2Positions[1]; 609 for (let i = allChildren.length - 1; i >= secondH2Pos; i--) { 610 if (allChildren[i].parentNode === this) { 611 $(allChildren[i]).detach(); 612 } 613 } 614 }); 615 } catch (error) { 616 console.error("Error in splitMultiHeadingDividers:", error); 617 reportStage("splitMultiHeadingDividers", error); // record for post-hoc diagnosis 618 } 619}; 620 621 /** 622 * Cleans up heading structure within dividers 623 * Removes duplicate headings, ensures proper heading hierarchy, 624 * and positions headings at the beginning of their containers 625 */ 626 const sanitizeHeadings = () => { 627 try { 628 $(".divider").each(function () { 629 const $divider = $(this); 630 const $headings = $divider.children("h2, h3, h4, h5, h6"); 631 const $h2Headings = $divider.children("h2"); 632 const hasContent = 633 $divider.children().length > 0 || 634 $divider.contents().filter(function () { 635 return this.nodeType === 3 && /\S/.test(this.nodeValue); 636 }).length > 0; 637 638 if ( 639 !hasContent || 640 ($headings.length && $headings.length === $divider.children().length) 641 ) { 642 $divider.remove(); 643 } 644 645 if ($h2Headings.length > 1) { 646 $h2Headings.slice(1).remove(); 647 } 648 649 if ( 650 !$headings.is(":first-child") && 651 !$(this).children().first().is("h1, h2, h3, h4, h5, h6") 652 ) { 653 $headings.first().detach().prependTo(this); 654 } 655 }); 656 } catch (error) { 657 console.error("Error in sanitizeHeadings:", error); 658 reportStage("sanitizeHeadings", error); // record for post-hoc diagnosis 659 } 660 }; 661 662 /** 663 * Moves images from headings to appropriate paragraphs 664 * Ensures images are properly displayed within content rather than headings 665 */ 666 const moveImagesToParagraphs = () => { 667 try { 668 paraTitles.each(function () { 669 const $title = $(this); 670 const $images = $title.find("img"); 671 672 if ($images.length) { 673 const $nextParagraph = $title.next("p"); 674 675 $images.each(function () { 676 const $image = $(this).detach(); 677 678 if ($nextParagraph.length) { 679 $nextParagraph.prepend($image); 680 } else if ( 681 lastParagraphWithoutImage && 682 lastParagraphWithoutImage.length 683 ) { 684 lastParagraphWithoutImage.prepend($image); 685 } 686 }); 687 } 688 }); 689 } catch (error) { 690 console.error("Error in moveImagesToParagraphs:", error); 691 reportStage("moveImagesToParagraphs", error); // record for post-hoc diagnosis 692 } 693 }; 694 695 /** 696 * Moves images from dividers to their first paragraphs 697 * Ensures consistent image placement within content 698 */ 699 const moveImagesToParagraphsFromDivider = () => { 700 try { 701 $(".divider").each(function () { 702 const $divider = $(this); 703 const $firstParagraph = $divider.find("p").first(); 704 const $images = $divider.find("img"); 705 706 if ($firstParagraph.length && $images.length) { 707 $images.slice(1).remove(); 708 $images.first().prependTo($firstParagraph); 709 } 710 }); 711 } catch (error) { 712 console.error("Error in moveImagesToParagraphsFromDivider:", error); 713 reportStage("moveImagesToParagraphsFromDivider", error); // record for post-hoc diagnosis 714 } 715 }; 716 717 /** 718 * Ensures headings are followed by paragraphs in dividers 719 * Improves content structure and readability 720 */ 721 const headingsFollowedByParagraph = () => { 722 try { 723 $(".divider").each(function () { 724 const $divider = $(this); 725 const $headings = $divider.children("h2, h3"); 726 727 $headings.each(function () { 728 const $heading = $(this); 729 const $next = $heading.next(); 730 if ($next.length && !$next.is("p, h1, h2, h3, h4, h5, h6")) { 731 $heading.next().detach().appendTo($divider); 732 } 733 }); 734 }); 735 } catch (error) { 736 console.error("Error in headingsFollowedByParagraph:", error); 737 reportStage("headingsFollowedByParagraph", error); // record for post-hoc diagnosis 738 } 739 }; 740 741 const addingFloatClass = () => { 742 try { 743 $(".divider").each(function () { 744 const $divider = $(this); 745 $divider 746 .find("p img") 747 .not(".img-float-left") 748 .addClass("img-float-left"); 749 750 if ($divider.find("div:has(img)").length) { 751 $divider.find("div:has(img)").after('<br class="clear" />'); 752 } 753 754 if ($divider.has("img").length) { 755 $divider.append('<br class="clear" />'); 756 } 757 }); 758 } catch (error) { 759 console.error("Error in addingFloatClass:", error); 760 reportStage("addingFloatClass", error); // record for post-hoc diagnosis 761 } 762 }; 763 764 /** 765 * Centers Google Maps iframes within the content 766 * Places iframes in the middle of the page for better visual presentation 767 */ 768 const centerGoogleMap = () => { 769 try { 770 // Moves the Iframe to the middle of the page 771 const dividerCount = $contentWrapper.find(".divider").length; 772 const GmapArray = $contentWrapper.find("iframe"); 773 774 if (GmapArray.length > 0) { 775 GmapArray.each(function () { 776 const iframe = $(this).detach(); 777 const mid = Math.floor(dividerCount / 2); 778 if (dividerCount > 1) { 779 const newMap = $(`<div class="divider"></div>`); 780 newMap.append(iframe); 781 $(".divider").eq(mid).after(newMap); 782 } 783 }); 784 } 785 } catch (error) { 786 console.error("Error in centerGoogleMap:", error); 787 reportStage("centerGoogleMap", error); // record for post-hoc diagnosis 788 } 789 }; 790 791 /** 792 * Removes empty lists from the page 793 * @param {string} list - Selector for the list container 794 * @param {string} target - Selector for the content elements within the list 795 */ 796 const removeEmptyLists = (list, target) => { 797 try { 798 // Remove empty lists 799 $(list).each(function () { 800 if ($(this).find(target).text().trim().length === 0) { 801 $(this).remove(); 802 } 803 }); 804 } catch (error) { 805 console.error("Error in removeEmptyLists:", error); 806 reportStage("removeEmptyLists", error); // record for post-hoc diagnosis 807 } 808 }; 809/**
810 * Merges dividers that contain only a heading (h2/h3) with no meaningful 811 * content (no <p>, <ul>, <ol>, images, or text) into their next sibling divider. 812 * Prevents orphaned headings from appearing as empty sections. 813 */ 814const mergeHeadingOnlyDividers = () => { 815 try { 816 $(".divider").each(function () { 817 const $divider = $(this); 818 const hasHeading = $divider.children("h2, h3, h4, h5, h6").length > 0; 819 820 if (!hasHeading) return; 821 822 // Check for meaningful content: <p>, <ul>, <ol>, <img>, or visible text 823 const hasContent = 824 $divider.children("p, ul, ol, img, iframe, table, blockquote").length > 0 || 825 $divider 826 .contents() 827 .filter(function () { 828 return this.nodeType === 3 && /\S/.test(this.nodeValue); 829 }).length > 0; 830 831 if (!hasContent) { 832 const $nextDivider = $divider.nextAll(".divider").first(); 833 if ($nextDivider.length) { 834 // Prepend heading into next divider so it becomes its section title 835 $nextDivider.prepend($divider.children("h2, h3, h4, h5, h6")); 836 $divider.remove(); 837 } 838 } 839 }); 840 } catch (error) { 841 console.error("Error in mergeHeadingOnlyDividers:", error); 842 reportStage("mergeHeadingOnlyDividers", error); // record for post-hoc diagnosis 843 } 844}; 845 846/** 847 * Handles dangling headings at the end of dividers ââ¬â cases where the 848 * last element is a heading (h2/h3) followed only by <br>, whitespace, 849 * or nothing. Moves the heading into the next divider so it isn't 850 * left as a heading with no content beneath it. 851 */ 852const fixDanglingHeadings = () => { 853 try { 854 $(".divider").each(function () { 855 const $divider = $(this); 856 const $children = $divider.children(); 857 858 if (!$children.length) return; 859 860 // Walk backwards past <br>, <hr>, and whitespace-only text nodes 861 let $last = $children.last(); 862 while ( 863 $last.length && 864 $last.is("br, hr, .clear") && 865 $last.prev().length 866 ) { 867 $last = $last.prev(); 868 } 869 870 // If the last real element is a heading, it's dangling 871 if ($last.is("h2, h3, h4, h5, h6")) { 872 const $nextDivider = $divider.nextAll(".divider").first(); 873 if ($nextDivider.length) { 874 $nextDivider.prepend($last); 875 } else { 876 $last.remove(); // No next section to go to ââ¬â orphaned 877 } 878 } 879 }); 880 } catch (error) { 881 console.error("Error in fixDanglingHeadings:", error); 882 reportStage("fixDanglingHeadings", error); // record for post-hoc diagnosis 883 } 884}; 885 886 887 888 /** 889 * ===================================================================== 890 * PIPELINE EXECUTION ORDER 891 * ===================================================================== 892 * ORDER MATTERS. Several stages depend on the output of earlier ones: 893 * - splitMultiHeadingDividers must run AFTER normalizeDivs and 894 * BEFORE sanitizeHeadings (per change log 02/09/2026). 895 * - fixDanglingHeadings should run BEFORE sanitizeHeadings so 896 * heading-only fragments get merged instead of deleted. 897 * - finalizeSanitization must always be LAST. 898 * If you reorder this array, re-read the change log at the top of 899 * this file first. 900 * ===================================================================== 901 */ 902 const stages = [ 903 ["repairWrapperIntegrity", repairWrapperIntegrity], 904 ["initialOuterCheck", initialOuterCheck], 905 ["cleanNestedTags", cleanNestedTags], 906 ["ensureDividers", ensureDividers], 907 ["normalizeDivs", normalizeDivs], 908 ["strayImages", strayImages], 909 ["organizeImagesInDividers", organizeImagesInDividers], 910 ["splitMultiHeadingDividers", splitMultiHeadingDividers], 911 ["sanitizeHeadings", sanitizeHeadings], 912 ["organizeHeadings", organizeHeadings], 913 ["absorbOrphanedLists", absorbOrphanedLists], 914 ["moveImagesToParagraphs", moveImagesToParagraphs], 915 ["moveImagesToParagraphsFromDivider", moveImagesToParagraphsFromDivider], 916 ["headingsFollowedByParagraph", headingsFollowedByParagraph], 917 ["addingFloatClass", addingFloatClass], 918 ["centerGoogleMap", centerGoogleMap], 919 ["removeEmptyLists", null], // needs args ââ¬â handled specially below 920 ["mergeHeadingOnlyDividers", mergeHeadingOnlyDividers],
921 ["fixDanglingHeadings", fixDanglingHeadings], 922 ]; 923 924 stages.forEach(function (pair) { 925 const name = pair[0]; 926 const fn = pair[1]; 927 try { 928 if (name === "removeEmptyLists") { 929 removeEmptyLists(".rubbish-grid-item .rubbish-link", "span.link-text"); 930 } else if (fn) { 931 fn(); 932 } 933 } catch (error) { 934 console.error("Error in " + name + ":", error); 935 reportStage(name, error); // record for post-hoc diagnosis (see logger docs) 936 } 937 }); 938 939 940 /** 941 * Performs final cleanup of the DOM after all other operations 942 * Removes empty elements, stray images, and cleans up any remaining formatting issues 943 */ 944 const finalizeSanitization = () => { 945 try { 946 const $dividers = $(".divider"); 947 const $paragraphs = $article.find("> p"); 948 949 // Remove any stray images left after the sanitization 950 $paragraphs.children("img").remove(); 951 // Remove stray images not properly sanitized 952 $(".divider > img").remove(); 953 954 // Remove and dividers left empty after sanitization 955 $dividers.each(function () { 956 if ($(this).children().length === 0) { 957 $(this).remove(); 958 } 959 }); 960 961 // Remove empty paragraphs // normalizeDivs() code; 962 $dividers 963 .filter(function () { 964 return ( 965 $(this).find("input, textarea, select, button, form").length === 0 && // Skip if it contains form elements to avoid layout interference 966 $(this).find("p").length === 0 && 967 $(this).find("iframe").length === 0 && 968 $(this).find("div.quote-banner").length === 0 && 969 $(this).find("h1, h2, h3, h4, h5, h6, ul, ol").length === 0 && 970 // Don't remove dividers with direct text content 971 !$(this).contents().filter(function () { 972 return this.nodeType === 3 && /\S/.test(this.nodeValue); 973 }).length 974 ); 975 }) 976 .remove(); 977 978 // Remove unpopulated images [IMG_0] 979 $("article,.divider").each(function () { 980 const $element = $(this); 981 982 // Only target text nodes containing [IMG_XX] without actual images 983 $element 984 .contents() 985 .filter(function () { 986 return this.nodeType === 3 && /\[IMG_\d+\]/.test(this.nodeValue); 987 }) 988 .each(function () { 989 // Replace only the [IMG_XX] pattern in text nodes 990 this.nodeValue = this.nodeValue.replace(/\[IMG_\d+\]/g, ""); 991 }); 992 993 // Clean empty paragraphs 994 $element 995 .find("p") 996 .filter(function () { 997 return !$.trim($(this).text()) && !$(this).children().length; 998 }) 999 .remove(); 1000 }); 1001 } catch (error) { 1002 console.error("Error in finalizeSanitization:", error); 1003 reportStage("finalizeSanitization", error); // record for post-hoc diagnosis 1004 } 1005 }; 1006 1007 finalizeSanitization(); 1008}); 1009 1010document.addEventListener("DOMContentLoaded", function () { 1011 try { 1012 document.querySelectorAll("p").forEach(function (element) { 1013 const voidElements = new Set([ 1014 "BR", 1015 "HR", 1016 "AREA", 1017 "BASE", 1018 "COL", 1019 "COMMAND", 1020 "EMBED", 1021 "KEYGEN", 1022 "LINK", 1023 "META", 1024 "SOURCE", 1025 "TRACK", 1026 "WBR", 1027 ]); 1028 let isSignificant = false; 1029 // Loop through child nodes and break if a significant node is found 1030 for (let node of element.childNodes) { 1031 if ( 1032 (node.nodeType === Node.TEXT_NODE && 1033 node.textContent.trim().length > 0) || 1034 (node.nodeType === Node.ELEMENT_NODE && 1035 !voidElements.has(node.nodeName)) 1036 ) { 1037 isSignificant = true; 1038 break; 1039 } 1040 } 1041 1042 // If no significant node is found, remove the <p> element 1043 if (!isSignificant) { 1044 element.remove(); 1045 } 1046 }); 1047 } catch (error) { 1048 console.error("Error cleaning empty paragraphs:", error); 1049 } 1050});
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.