PageSourceSearch

https://skiphiresouthfields.co.uk/js/utilities.js

js skiphiresouthfields.co.uk collected 2026-10-07 05:46:10 UTC 39,602 bytes, 1,050 lines download raw bytes

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.