PageSourceSearch

https://www.arlnow.com/wp-content/plugins/lnn-core/source/js/infinite-scroll.js?ver=6.7.9

js arlnow.com collected 2026-10-01 10:40:46 UTC 37,181 bytes, 1,050 lines download raw bytes

1/**
2 * Infinite Scroll functionality for single post pages.
3 *
4 * @package LocalNewsNow\Core
5 */
6
7(function($) {
8    'use strict';
9
10    let isLoading = false;
11    let hasMore = true;
12    /** Post ID reflected in URL bar / analytics (what the reader has reached). */
13    let currentPostId = null;
14    /**
15     * Chronological anchor for the next load-more request (oldest post in the loaded river).
16     * Must not be overwritten by scroll; only by successful load-more responses.
17     */
18    let riverAnchorPostId = null;
19    let originalPostId = null; // Track the original post ID (first post on page)
20    let loadedPosts = 1;
21    let lastUrlUpdateTime = 0;
22    let lastAuthorTriggerTime = 0;
23    /** Post ID last sent to analytics (GA4 / Clicky); avoids duplicate pageviews for the same article. */
24    let lastAnalyticsPostId = null;
25    let originalDocumentTitleSuffix = '';
26    /** Client-side record of every story ID appended to the river (exclude list for AJAX). */
27    const loadedRiverPostIds = {};
28    /** Blocks river load-more and URL churn while landing on a comments deep link. */
29    let commentsLandingActive = false;
30    let commentsLandingReleaseTimer = null;
31    /** Fallback if scroll-to-comments never fires (slow JS, missing target, etc.). */
32    const COMMENTS_LANDING_MAX_MS = 5000;
33    /**
34     * After lnn:comments:landed, hold the lock briefly so Disqus async embed/layout-shift
35     * cannot trigger load-more or URL churn before the reader has settled on article #1.
36     */
37    const COMMENTS_LANDED_DISQUS_GRACE_MS = 3000;
38
39    /**
40     * Whether the reader arrived to open comments (homepage, river Show Comments, etc.).
41     *
42     * @return {boolean}
43     */
44    function isCommentsDeepLink() {
45        try {
46            if (new URLSearchParams(window.location.search).get('comments') === '1') {
47                return true;
48            }
49        } catch (err) {
50            // ignore
51        }
52        const hash = window.location.hash || '';
53        return hash === '#disqus_thread' || hash.indexOf('#comment-') === 0;
54    }
55
56    /**
57     * Keep URL/title on the article the reader clicked until comments landing finishes.
58     */
59    function pinUrlToOriginalPost() {
60        if (!originalPostId) {
61            return;
62        }
63        currentPostId = originalPostId;
64        updateUrlToVisiblePost(originalPostId);
65    }
66
67    /**
68     * End comments-landing protection. Re-pin article #1 instead of recomputing from scroll
69     * position — the reader is usually still at the bottom of that article when the lock lifts.
70     */
71    function releaseCommentsLanding() {
72        if (!commentsLandingActive) {
73            return;
74        }
75        commentsLandingActive = false;
76        commentsLandingReleaseTimer = null;
77        pinUrlToOriginalPost();
78    }
79
80    function scheduleCommentsLandingRelease(delayMs) {
81        if (commentsLandingReleaseTimer) {
82            clearTimeout(commentsLandingReleaseTimer);
83        }
84        commentsLandingReleaseTimer = setTimeout(releaseCommentsLanding, delayMs);
85    }
86
87    function beginCommentsLanding() {
88        commentsLandingActive = true;
89        currentPostId = originalPostId;
90        scheduleCommentsLandingRelease(COMMENTS_LANDING_MAX_MS);
91    }
92
93    // Initialize infinite scroll when DOM is ready
94    $(document).ready(function() {
95        if (typeof window.lnnInfiniteScrollConfig === 'undefined') {
96            return;
97        }
98
99        const config = window.lnnInfiniteScrollConfig;
100
101        if (!config.enabled) {
102            return;
103        }
104
105        currentPostId = config.currentPostId;
106        riverAnchorPostId = config.currentPostId;
107        originalPostId = config.currentPostId; // Store the original post ID
108        rememberLoadedRiverPostId(originalPostId);
109        loadedPosts = config.loadedPosts;
110        hasMore = config.hasMore;
111        lastAnalyticsPostId = String(originalPostId);
112        // Prefer suffix from PHP (wp_get_document_title at render). Client-side parsing of
113        // document.title can treat a previous story's headline as part of the "suffix" when
114        // two titles were merged with " - " or when the site uses separators we did not match.
115        originalDocumentTitleSuffix =
116            typeof config.documentTitleSuffix === 'string' && config.documentTitleSuffix !== ''
117                ? config.documentTitleSuffix
118                : getOriginalDocumentTitleSuffix();
119
120        // Add loading indicator
121        addLoadingIndicator();
122
123        // Add end of content indicator (but don't show it yet)
124        addEndOfContentIndicator(null);
125
126        // Set up scroll listener
127        $(window).on('scroll', throttle(handleScroll, 200));
128
129        // Set up scroll-based URL updating
130        $(window).on('scroll', throttle(handleScrollForUrlUpdate, 100));
131
132        // When returning to a visible tab, sync URL once (no updates while document.hidden).
133        $(document).on('visibilitychange.lnnInfScroll', function() {
134            if (!document.hidden) {
135                handleScrollForUrlUpdate();
136            }
137        });
138
139        // Initialize URL cache with current post
140        initializeUrlCache();
141
142        // Do not touch history on initial page load: the browser already has the correct post URL.
143        // Pushing it again makes Back land on the same article before returning to the referrer.
144
145        // Same-origin navigations inside the river (except Show Comments): replace the current
146        // article entry so Back from the destination leaves the river in one step.
147        setupRiverOutboundLinkReplace();
148
149        // Comments deep links scroll to the bottom of the first article, which otherwise
150        // triggers load-more and URL updates for the next river post before Disqus renders.
151        if (isCommentsDeepLink()) {
152            beginCommentsLanding();
153            $(document).on('lnn:comments:landed.lnnInfScroll', function() {
154                pinUrlToOriginalPost();
155                // Primary release: shortly after comments scroll into view (Disqus grace).
156                // Replaces the longer max fallback from beginCommentsLanding().
157                scheduleCommentsLandingRelease(COMMENTS_LANDED_DISQUS_GRACE_MS);
158            });
159        }
160    });
161
162    /**
163     * Update the address bar during infinite scroll without growing the history stack.
164     *
165     * @param {object} state Arbitrary History state object.
166     * @param {string} title Browser title for this state.
167     * @param {string} url Canonical post URL for the active article.
168     */
169    function replaceRiverUrl(state, title, url) {
170        if (!window.history || !window.history.replaceState) {
171            return;
172        }
173        window.history.replaceState(state, title, url);
174    }
175
176    /**
177     * Decode titles returned by AJAX before assigning document.title.
178     *
179     * @param {string} value Raw title text, possibly containing HTML entities.
180     * @return {string}
181     */
182    function decodeHtmlEntities(value) {
183        const textarea = document.createElement('textarea');
184        textarea.innerHTML = String(value || '');
185        return textarea.value;
186    }
187
188    /**
189     * Keep the original site suffix so title updates never prepend a new title onto an already-mutated title.
190     *
191     * @return {string}
192     */
193    function getOriginalDocumentTitleSuffix() {
194        const pageTitle = decodeHtmlEntities(document.title).trim();
195
196        // Prefer the trailing site brand (… | Publisher). If we strip using the H1 prefix first,
197        // a title that is already malformed ("Headline - Headline | Site") keeps the extra headline
198        // in the "suffix" and every virtual title update repeats it.
199        const pipeIndex = pageTitle.lastIndexOf(' | ');
200        if (pipeIndex !== -1) {
201            return pageTitle.slice(pipeIndex);
202        }
203
204        const enDashIndex = pageTitle.lastIndexOf(' – ');
205        if (enDashIndex !== -1) {
206            return pageTitle.slice(enDashIndex);
207        }
208
209        const currentPostTitle = decodeHtmlEntities(
210            $('#post-' + currentPostId).find('h1, h2').first().text()
211        ).trim();
212
213        if (currentPostTitle && pageTitle.indexOf(currentPostTitle) === 0) {
214            return pageTitle.slice(currentPostTitle.length);
215        }
216
217        const dashIndex = pageTitle.lastIndexOf(' - ');
218        if (dashIndex !== -1) {
219            return pageTitle.slice(dashIndex);
220        }
221
222        return '';
223    }
224
225    /**
226     * Build a clean document title from one decoded article title plus the original site suffix.
227     *
228     * @param {string} postTitle Article title.
229     * @return {string}
230     */
231    function buildDocumentTitle(postTitle) {
232        const title = decodeHtmlEntities(postTitle).trim();
233        if (!title) {
234            return document.title;
235        }
236        return title + originalDocumentTitleSuffix;
237    }
238
239    /**
240     * In-river same-origin navigations replace the river entry ([referrer, dest]).
241     * Show Comments first restores the current history entry to the original page for this
242     * river session, then loads the comments page so Back returns there.
243     */
244    function setupRiverOutboundLinkReplace() {
245        $(document).on(
246            'click.lnnRiverNav',
247            '.infinite-scroll-container a[href]',
248            function(e) {
249                if (e.defaultPrevented || e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) {
250                    return;
251                }
252
253                const a = this;
254                if (a.target && String(a.target).toLowerCase() === '_blank') {
255                    return;
256                }
257                if (a.hasAttribute('download')) {
258                    return;
259                }
260
261                const hrefAttr = ($(a).attr('href') || '').trim();
262                if (!hrefAttr || hrefAttr === '#') {
263                    return;
264                }
265                if (/^(javascript:|mailto:|tel:|sms:)/i.test(hrefAttr)) {
266                    return;
267                }
268                if ($(a).closest('.river-comments-button').length || $(a).hasClass('river-comments-button')) {
269                    e.preventDefault();
270                    replaceCurrentEntryWithOriginalPost();
271                    window.location.href = a.href;
272                    return;
273                }
274
275                let targetUrl;
276                try {
277                    targetUrl = new URL(a.href);
278                } catch (err) {
279                    return;
280                }
281
282                if (targetUrl.origin !== window.location.origin) {
283                    return;
284                }
285                if (targetUrl.pathname.indexOf('/wp-admin') === 0 || targetUrl.pathname.indexOf('/wp-login.php') === 0) {
286                    return;
287                }
288
289                if (
290                    targetUrl.pathname === window.location.pathname &&
291                    targetUrl.search === window.location.search &&
292                    targetUrl.hash !== window.location.hash
293                ) {
294                    return;
295                }
296
297                e.preventDefault();
298                window.location.replace(targetUrl.href);
299            }
300        );
301    }
302
303    /**
304     * Before opening a river article's comments, make the Back destination the article page
305     * that owns this river session (for example Post B), not the virtual URL of the comments
306     * article (for example Post C).
307     */
308    function replaceCurrentEntryWithOriginalPost() {
309        if (!originalPostId || !window.lnnInfiniteScrollPostUrls) {
310            return;
311        }
312
313        const originalPostData = window.lnnInfiniteScrollPostUrls[originalPostId];
314        if (!originalPostData || !originalPostData.url) {
315            return;
316        }
317
318        const cleanTitle = buildDocumentTitle(originalPostData.title);
319        replaceRiverUrl(
320            { postId: originalPostId },
321            cleanTitle,
322            originalPostData.url
323        );
324        document.title = cleanTitle;
325    }
326
327    /**
328     * Handle scroll event for infinite scroll.
329     */
330    function handleScroll() {
331        if (commentsLandingActive || isLoading || !hasMore) {
332            return;
333        }
334
335        // Check if author section is in view
336        const authorSection = $('.pp-multiple-authors-boxes-wrapper, .author-section, [class*="author"]').last();
337        if (authorSection.length) {
338            const authorTop = authorSection.offset().top;
339            const authorHeight = authorSection.outerHeight();
340            const scrollTop = $(window).scrollTop();
341            const windowHeight = $(window).height();
342
343            // Trigger when author section is 300px from viewport top
344            if (authorTop - scrollTop < windowHeight - 300) {
345                const now = Date.now();
346                // Prevent multiple triggers within 2 seconds
347                if (now - lastAuthorTriggerTime > 2000) {
348                    console.log('Infinite scroll: Author section in view, loading next post');
349                    lastAuthorTriggerTime = now;
350                    loadMorePosts();
351                    return;
352                }
353            }
354        }
355
356        // Fallback: trigger when user is 200px from bottom
357        const scrollTop = $(window).scrollTop();
358        const windowHeight = $(window).height();
359        const documentHeight = $(document).height();
360
361        if (scrollTop + windowHeight >= documentHeight - 200) {
362            loadMorePosts();
363        }
364    }
365
366    /**
367     * Handle scroll event for URL updates based on visible post.
368     */
369    function handleScrollForUrlUpdate() {
370        if (commentsLandingActive || document.hidden) {
371            return;
372        }
373        const now = Date.now();
374        // Debounce URL updates to prevent too frequent changes
375        if (now - lastUrlUpdateTime < 500) {
376            return;
377        }
378
379        const visiblePostId = getCurrentlyVisiblePostId();
380        if (visiblePostId && String(visiblePostId) !== String(currentPostId)) {
381            currentPostId = visiblePostId;
382            lastUrlUpdateTime = now;
383            updateUrlToVisiblePost(visiblePostId);
384        }
385    }
386
387    /**
388     * Track post IDs already in the river so AJAX excludes stay complete even if the DOM scan misses one.
389     *
390     * @param {number|string} postId Post ID.
391     */
392    function rememberLoadedRiverPostId(postId) {
393        const n = parseInt(postId, 10);
394        if (!isNaN(n) && n > 0) {
395            loadedRiverPostIds[n] = true;
396        }
397    }
398
399    /**
400     * @param {Array<number|string>} postIds Post IDs.
401     */
402    function rememberLoadedRiverPostIds(postIds) {
403        if (!Array.isArray(postIds)) {
404            return;
405        }
406        postIds.forEach(function(postId) {
407            rememberLoadedRiverPostId(postId);
408        });
409    }
410
411    /**
412     * Post IDs currently in the river (lead + AJAX-appended articles) for excluding from the next batch.
413     *
414     * @return {number[]}
415     */
416    function getLoadedRiverPostIdsForAjax() {
417        const seen = Object.assign({}, loadedRiverPostIds);
418
419        $('.infinite-scroll-container article[id^="post-"], .site-main article[id^="post-"]').each(function() {
420            const raw = ($(this).attr('id') || '').replace(/^post-/, '');
421            const n = parseInt(raw, 10);
422            if (!isNaN(n) && n > 0) {
423                seen[n] = true;
424            }
425        });
426
427        if (window.lnnInfiniteScrollPostUrls && typeof window.lnnInfiniteScrollPostUrls === 'object') {
428            Object.keys(window.lnnInfiniteScrollPostUrls).forEach(function(id) {
429                const n = parseInt(id, 10);
430                if (!isNaN(n) && n > 0) {
431                    seen[n] = true;
432                }
433            });
434        }
435
436        const origInt = parseInt(originalPostId, 10);
437        if (!isNaN(origInt) && origInt > 0) {
438            seen[origInt] = true;
439        }
440
441        const out = [];
442        Object.keys(seen).forEach(function(k) {
443            out.push(parseInt(k, 10));
444        });
445
446        return out;
447    }
448
449    /**
450     * Load more posts via AJAX.
451     */
452    function loadMorePosts() {
453        if (isLoading || !hasMore) {
454            return;
455        }
456
457        isLoading = true;
458        showLoadingIndicator();
459
460        const config = window.lnnInfiniteScrollConfig;
461        const loadedPostIds = getLoadedRiverPostIdsForAjax();
462
463        const data = {
464            action: 'load_more_posts',
465            nonce: lnnInfiniteScroll.nonce,
466            current_post_id: riverAnchorPostId,
467            loaded_posts: loadedPosts,
468            post_limit: config.postLimit,
469            original_post_id: originalPostId,
470            loaded_post_ids: loadedPostIds
471        };
472
473        $.ajax({
474            url: lnnInfiniteScroll.ajaxUrl,
475            type: 'POST',
476            data: data,
477            success: function(response) {
478                if (response.success) {
479                    handleLoadMoreSuccess(response.data);
480                } else {
481                    handleLoadMoreError(response.data);
482                }
483            },
484            error: function(xhr, status, error) {
485                handleLoadMoreError('AJAX Error: ' + error);
486            },
487            complete: function() {
488                isLoading = false;
489                hideLoadingIndicator();
490            }
491        });
492    }
493
494    /**
495     * Handle successful load more response.
496     */
497    function handleLoadMoreSuccess(data) {
498        const incomingIds = Object.keys(data.postUrls || {}).map(function(id) {
499            return parseInt(id, 10);
500        }).filter(function(n) {
501            return !isNaN(n) && n > 0;
502        });
503
504        const hasNewContent = Boolean(data.html && data.html.length) && incomingIds.some(function(id) {
505            return !loadedRiverPostIds[id];
506        });
507
508        if (!hasNewContent) {
509            loadedPosts = data.loadedPosts;
510            hasMore = false;
511            riverAnchorPostId = data.nextPostId;
512            hideLoadingIndicator();
513            showEndOfContentIndicator(data.nextPostUrl);
514            return;
515        }
516
517        // Append new posts to the content
518        $('.site-main').append(data.html);
519        rememberLoadedRiverPostIds(incomingIds);
520
521        // Update counters
522        loadedPosts = data.loadedPosts;
523        hasMore = data.hasMore;
524        riverAnchorPostId = data.nextPostId;
525
526        // Cache post URLs for scroll-based URL updates (do not change URL/analytics here — user may
527        // still be reading higher on the page; idle tabs were also jumping to the batch tail).
528        if (data.postUrls) {
529            if (!window.lnnInfiniteScrollPostUrls) {
530                window.lnnInfiniteScrollPostUrls = {};
531            }
532            Object.assign(window.lnnInfiniteScrollPostUrls, data.postUrls);
533        }
534
535        // Hide loading indicator
536        hideLoadingIndicator();
537
538        // Initialize any new galleries inside newly appended content
539        try {
540            initNewGalleries();
541        } catch (e) {
542            console.warn('Infinite scroll: gallery init failed', e);
543        }
544
545        // Show end message if no more posts
546        if (!hasMore) {
547            console.log('Infinite scroll: No more posts, showing end message');
548            showEndOfContentIndicator(data.nextPostUrl);
549        } else {
550            console.log('Infinite scroll: More posts available, loadedPosts:', loadedPosts, 'hasMore:', hasMore);
551        }
552
553        // Trigger custom event for other scripts
554        $(document).trigger('lnn:infiniteScroll:loaded', {
555            loadedPosts: loadedPosts,
556            hasMore: hasMore,
557            currentPostId: currentPostId,
558            riverAnchorPostId: riverAnchorPostId
559        });
560    }
561
562    /**
563     * Handle load more error.
564     */
565    function handleLoadMoreError(error) {
566        console.error('Infinite scroll error:', error);
567        hasMore = false;
568        showEndOfContentIndicator(null);
569    }
570
571    /**
572     * Initialize URL cache with current post data.
573     */
574    function initializeUrlCache() {
575        if (!window.lnnInfiniteScrollPostUrls) {
576            window.lnnInfiniteScrollPostUrls = {};
577        }
578
579        // Add current post to cache
580        const currentPostElement = $('#post-' + currentPostId);
581        if (currentPostElement.length) {
582            const currentPostTitle = decodeHtmlEntities(currentPostElement.find('h1, h2').first().text()).trim();
583            window.lnnInfiniteScrollPostUrls[currentPostId] = {
584                url: window.location.href,
585                title: currentPostTitle || decodeHtmlEntities(document.title).replace(originalDocumentTitleSuffix, '').trim()
586            };
587        }
588    }
589
590    /**
591     * Vertical position used to decide "has the reader reached this story?" — published dateline
592     * (`header time[datetime]`) when present, else start of body, else article top.
593     *
594     * @param {JQuery} $post Article element.
595     * @return {number}
596     */
597    function getReadingMarkerTop($post) {
598        const $time = $post.find('header time[datetime]').first();
599        if ($time.length) {
600            return $time.offset().top;
601        }
602        const $entry = $post.find('.entry-content').first();
603        if ($entry.length) {
604            return $entry.offset().top;
605        }
606        return $post.offset().top;
607    }
608
609    /**
610     * Legacy: pick article by viewport center + visibility (headline-heavy).
611     */
612    function getCurrentlyVisiblePostIdLegacy() {
613        const scrollTop = $(window).scrollTop();
614        const windowHeight = $(window).height();
615        const viewportCenter = scrollTop + (windowHeight / 2);
616
617        let mostVisiblePost = null;
618        let bestVisibilityScore = 0;
619
620        $('.infinite-scroll-container article[id^="post-"]').each(function() {
621            const $post = $(this);
622            const postId = $post.attr('id').replace('post-', '');
623            const postTop = $post.offset().top;
624            const postHeight = $post.outerHeight();
625            const postBottom = postTop + postHeight;
626
627            const visibleTop = Math.max(postTop, scrollTop);
628            const visibleBottom = Math.min(postBottom, scrollTop + windowHeight);
629            const visibleHeight = Math.max(0, visibleBottom - visibleTop);
630            const visibilityRatio = visibleHeight / Math.min(postHeight, windowHeight);
631
632            const postCenter = postTop + (postHeight / 2);
633            const distanceFromCenter = Math.abs(postCenter - viewportCenter);
634            const centerScore = Math.max(0, 1 - (distanceFromCenter / windowHeight));
635
636            const mostlyVisibleBonus = visibilityRatio > 0.5 ? 0.2 : 0;
637
638            const totalScore = (visibilityRatio * 0.6) + (centerScore * 0.3) + mostlyVisibleBonus;
639
640            if (totalScore > bestVisibilityScore) {
641                bestVisibilityScore = totalScore;
642                mostVisiblePost = postId;
643            }
644        });
645
646        return mostVisiblePost;
647    }
648
649    /**
650     * Prefer dateline / reading marker in the upper viewport band (conservative for analytics + URL).
651     */
652    function getCurrentlyVisiblePostIdByDateline() {
653        const scrollTop = $(window).scrollTop();
654        const vh = $(window).height();
655        const bandTop = scrollTop;
656        const bandBottom = scrollTop + vh * 0.42;
657
658        let best = null;
659        let bestDist = Infinity;
660
661        $('.infinite-scroll-container article[id^="post-"]').each(function() {
662            const $post = $(this);
663            const postId = $post.attr('id').replace('post-', '');
664            const markTop = getReadingMarkerTop($post);
665            const $time = $post.find('header time[datetime]').first();
666            const markH = Math.max($time.outerHeight() || 24, 20);
667            if (markTop > bandBottom) {
668                return;
669            }
670            if (markTop + markH < bandTop) {
671                return;
672            }
673
674            const target = scrollTop + vh * 0.22;
675            const dist = Math.abs(markTop + markH / 2 - target);
676            if (dist < bestDist) {
677                bestDist = dist;
678                best = postId;
679            }
680        });
681
682        return best;
683    }
684
685    /**
686     * Get the active post for URL + analytics: dateline-first, legacy fallback near top of page.
687     */
688    function getCurrentlyVisiblePostId() {
689        if (document.hidden) {
690            return null;
691        }
692        const byDateline = getCurrentlyVisiblePostIdByDateline();
693        if (byDateline) {
694            return byDateline;
695        }
696        const scrollTop = $(window).scrollTop();
697        if (scrollTop < 120) {
698            return String(originalPostId);
699        }
700        return getCurrentlyVisiblePostIdLegacy();
701    }
702
703    /**
704     * Update URL to the currently visible post.
705     */
706    function updateUrlToVisiblePost(postId) {
707        // Check if we already have the URL cached from previous load
708        if (window.lnnInfiniteScrollPostUrls && window.lnnInfiniteScrollPostUrls[postId]) {
709            const postData = window.lnnInfiniteScrollPostUrls[postId];
710            updateUrlFromData(postData, postId);
711            return;
712        }
713
714        // Otherwise, fetch the URL via AJAX
715        updateUrl(postId);
716    }
717
718    /**
719     * Fire virtual pageviews when the active article changes (GA4, Clicky).
720     * Skips the first article: the initial HTML load is already counted by sitewide tags.
721     *
722     * @param {string|number} postId
723     * @param {{ url: string, title?: string|null }} postData Plain post headline when set; omit or leave empty to use document.title.
724     */
725    function trackVirtualPageview(postId, postData) {
726        if (!postData || !postData.url) {
727            return;
728        }
729        const id = String(postId);
730        if (id === lastAnalyticsPostId) {
731            return;
732        }
733        lastAnalyticsPostId = id;
734
735        // Match document.title: only build from a plain post headline (cache / AJAX). Do not pass
736        // document.title through buildDocumentTitle or the site suffix is applied twice.
737        const rawTitle = postData.title;
738        const title =
739            rawTitle != null && String(rawTitle).trim() !== ''
740                ? buildDocumentTitle(rawTitle)
741                : document.title;
742        const pageLocation = postData.url;
743
744        if (typeof window.gtag === 'function') {
745            window.gtag('event', 'page_view', {
746                page_title: title,
747                page_location: pageLocation
748            });
749        }
750
751        if (typeof window.clicky !== 'undefined' && typeof window.clicky.log === 'function') {
752            window.clicky.log(pageLocation, title, 'pageview');
753        }
754
755        $(document).trigger('lnn:infiniteScroll:virtualPageview', {
756            postId: id,
757            title: title,
758            url: pageLocation
759        });
760    }
761
762    /**
763     * Update browser URL using data from AJAX response.
764     */
765    function updateUrlFromData(postData, postId) {
766        if (!postData || !postData.url || !window.history || !window.history.replaceState) {
767            return;
768        }
769        const cleanTitle = buildDocumentTitle(postData.title);
770
771        replaceRiverUrl(
772            { postId: postId },
773            cleanTitle,
774            postData.url
775        );
776
777        // Update page title
778        document.title = cleanTitle;
779
780        trackVirtualPageview(postId, {
781            url: postData.url,
782            title: decodeHtmlEntities(postData.title)
783        });
784    }
785
786    /**
787     * Update browser URL without page reload.
788     */
789    function updateUrl(postId) {
790        if (!postId) {
791            return;
792        }
793
794        // Fetch the full post URL via AJAX
795        $.ajax({
796            url: lnnInfiniteScroll.ajaxUrl,
797            type: 'POST',
798            data: {
799                action: 'get_post_url',
800                nonce: lnnInfiniteScroll.nonce,
801                post_id: postId
802            },
803            success: function(response) {
804                if (response.success && response.data.url && window.history && window.history.replaceState) {
805                    const cleanTitle = buildDocumentTitle(response.data.title);
806
807                    replaceRiverUrl(
808                        { postId: postId },
809                        cleanTitle,
810                        response.data.url
811                    );
812
813                    // Update page title
814                    document.title = cleanTitle;
815
816                    trackVirtualPageview(postId, {
817                        url: response.data.url,
818                        title: decodeHtmlEntities(response.data.title)
819                    });
820                }
821            },
822            error: function() {
823                // Fallback: construct URL from current structure
824                const currentUrl = window.location.href;
825                const baseUrl = currentUrl.split('/').slice(0, -1).join('/');
826                const fallbackUrl = baseUrl + '/' + postId + '/';
827
828                if (window.history && window.history.replaceState) {
829                    replaceRiverUrl(
830                        { postId: postId },
831                        document.title,
832                        fallbackUrl
833                    );
834                    trackVirtualPageview(postId, { url: fallbackUrl });
835                }
836            }
837        });
838    }
839
840
841    /**
842     * Add loading indicator to the page.
843     */
844    function addLoadingIndicator() {
845        const config = window.lnnInfiniteScrollConfig;
846        const loadingHtml = `
847            <div id="infinite-scroll-loading" class="infinite-scroll-loading" style="display: none;">
848                <div class="text-center py-8">
849                    <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
850                    <p class="mt-2 text-gray-600">${config.loadingText}</p>
851                </div>
852            </div>
853        `;
854        $('.site-main').append(loadingHtml);
855    }
856
857    /**
858     * Show loading indicator.
859     */
860    function showLoadingIndicator() {
861        $('#infinite-scroll-loading').show();
862    }
863
864    /**
865     * Hide loading indicator.
866     */
867    function hideLoadingIndicator() {
868        $('#infinite-scroll-loading').hide();
869    }
870
871    /**
872     * Initialize newly added galleries (slick sliders) within the document.
873     * Mirrors theme's gallery.js behavior while avoiding double-initialization.
874     */
875    function initNewGalleries() {
876        // Require slick if present (already loaded by theme bundle)
877        if (typeof $.fn.slick !== 'function') {
878            return;
879        }
880
881        const $newGalleries = $('.js-gallery').filter(function() {
882            // Mark initialized galleries to avoid re-binding
883            return !$(this).data('lnn-gallery-initialized');
884        });
885
886        $newGalleries.each(function() {
887            const $gallery = $(this);
888            const $slider = $gallery.find('.gallery__slider');
889            const $footer = $gallery.find('.gallery__footer');
890            const $footerMeta = $footer.find('.gallery__meta');
891            const $nav = $gallery.find('.gallery__nav');
892            const $next = $nav.find('.nav__next');
893            const $prev = $nav.find('.nav__prev');
894            const $count = $nav.find('.nav__count');
895
896            if ($slider.hasClass('slick-initialized')) {
897                return; // already initialized
898            }
899
900            $slider.slick({
901                infinite: true,
902                rows: 0,
903                slidestoshow: 1,
904                slidestoscroll: 1,
905                arrows: false,
906                draggable: true,
907                adaptiveHeight: true,
908                lazyLoad: 'progressive',
909                adapativeHeight: true,
910            });
911
912            // Initial meta/count
913            const initialCaption = $slider.find('.slick-current .gallery__meta').html();
914            if ($footerMeta.length) {
915                $footerMeta.html(initialCaption);
916            }
917
918            $next.on('click', function() { $slider.slick('slickNext'); });
919            $prev.on('click', function() { $slider.slick('slickPrev'); });
920            $slider.on('click', function() { $slider.slick('slickNext'); });
921
922            $slider.on('afterChange', function(event, slick, currentSlide) {
923                const slideCaption = $(slick.$slides[currentSlide]).find('.gallery__meta').html();
924                if ($footerMeta.length) {
925                    $footerMeta.html(slideCaption);
926                }
927                if ($count.length) {
928                    $count.text((currentSlide + 1) + '/' + slick.slideCount);
929                }
930            });
931
932            // Aspect ratio helper (match theme)
933            $gallery.find('.gallery__img img').each(function() {
934                const img = this;
935                const width = parseInt(img.getAttribute('width') || img.naturalWidth || 0, 10);
936                const height = parseInt(img.getAttribute('height') || img.naturalHeight || 0, 10);
937                if (!width || !height) { return; }
938                function gcd(a,b){ return b === 0 ? a : gcd(b, a % b); }
939                const g = gcd(width, height);
940                const ratio = (width/g) + ':' + (height/g);
941                if (['3:2','16:9','4:3'].indexOf(ratio) === -1) {
942                    img.classList.add('aspect-ratio-custom');
943                }
944            });
945
946            // Mark as initialized
947            $gallery.data('lnn-gallery-initialized', true);
948        });
949    }
950
951    /**
952     * Add end of content indicator.
953     *
954     * @param {string|null} nextPostUrl URL of the next post, or null if no next post.
955     */
956    function addEndOfContentIndicator(nextPostUrl) {
957        const config = window.lnnInfiniteScrollConfig;
958        let endContent = '';
959
960        if (nextPostUrl) {
961            // Show button to view next post
962            const escapedUrl = $('<div>').text(nextPostUrl).html(); // Escape URL to prevent XSS
963            endContent = `
964                <div class="text-center py-8">
965                    <a href="${escapedUrl}" class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-midnight text-white rounded-md hover:bg-opacity-90 transition-all duration-150 font-sans text-lg font-semibold no-underline">
966                        <span style="color: #000">View Next Post</span>
967                        <span class="inline-block size-6" style="transform: rotate(-90deg);"><svg class="text-midnight w-full h-full" width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
968                        </span>
969                    </a>
970                </div>
971            `;
972        } else {
973            // Show default end message
974            endContent = `
975                <div class="text-center py-8">
976                    <p class="text-gray-600">${config.endText}</p>
977                </div>
978            `;
979        }
980
981        const endHtml = `
982            <div id="infinite-scroll-end" class="infinite-scroll-end" style="display: none;">
983                ${endContent}
984            </div>
985        `;
986        // Always append to the container so it's after all post and sidebar content
987        const container = $('.infinite-scroll-container');
988        if (container.length) {
989            container.append(endHtml);
990        } else {
991            // Fallback to appending to site-main if container isn't found
992            $('.site-main').append(endHtml);
993        }
994    }
995
996    /**
997     * Show end of content indicator.
998     *
999     * @param {string|null} nextPostUrl URL of the next post, or null if no next post.
1000     */
1001    function showEndOfContentIndicator(nextPostUrl) {
1002        // Remove any existing end messages first
1003        $('#infinite-scroll-end').remove();
1004
1005        // Add the end message after the last post
1006        addEndOfContentIndicator(nextPostUrl);
1007
1008        // Show the end message
1009        $('#infinite-scroll-end').show();
1010    }
1011
1012    /**
1013     * Throttle function to limit scroll event frequency.
1014     */
1015    function throttle(func, wait) {
1016        let timeout;
1017        return function executedFunction(...args) {
1018            const later = () => {
1019                clearTimeout(timeout);
1020                func(...args);
1021            };
1022            clearTimeout(timeout);
1023            timeout = setTimeout(later, wait);
1024        };
1025    }
1026
1027    // Handle browser back/forward buttons
1028    window.addEventListener('popstate', function(event) {
1029        if (!event.state || !event.state.postId) {
1030            return;
1031        }
1032        const pid = event.state.postId;
1033        currentPostId = pid;
1034
1035        const cached = window.lnnInfiniteScrollPostUrls && window.lnnInfiniteScrollPostUrls[pid];
1036        const postData = cached ? cached : { url: window.location.href };
1037        trackVirtualPageview(pid, postData);
1038
1039        if (document.hidden) {
1040            return;
1041        }
1042        const postElement = $('#post-' + pid);
1043        if (postElement.length) {
1044            $('html, body').animate({
1045                scrollTop: postElement.offset().top - 100
1046            }, 500);
1047        }
1048    });
1049
1050})(jQuery);

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.