nodebb-plugin-ezoic-infinite 1.6.8 → 1.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,6 +1,24 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
+ function createWrapElement(kindClass, targetEl) {
5
+ try {
6
+ if (kindClass === 'ezoic-ad-between') {
7
+ var p = targetEl && targetEl.parentElement;
8
+ if (p && (p.tagName === 'UL' || p.tagName === 'OL')) {
9
+ var li = document.createElement('li');
10
+ li.className = 'nodebb-ezoic-wrap ' + kindClass;
11
+ li.setAttribute('role', 'listitem');
12
+ return li;
13
+ }
14
+ }
15
+ } catch (e) {}
16
+ var div = document.createElement('div');
17
+ div.className = 'nodebb-ezoic-wrap ' + kindClass;
18
+ return div;
19
+ }
20
+
21
+
4
22
  // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
5
23
  // Recycling while scrolling up is a common cause of ads "bunching" and a "disappearing too fast" feeling.
6
24
  let lastScrollY = 0;
@@ -637,7 +655,7 @@ function globalGapFixInit() {
637
655
  // ---------------- insertion primitives ----------------
638
656
 
639
657
  function buildWrap(id, kindClass, afterPos, createPlaceholder) {
640
- const wrap = document.createElement('div');
658
+ const wrap = createWrapElement(kindClass, el);
641
659
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
642
660
  wrap.setAttribute('data-ezoic-after', String(afterPos));
643
661
  wrap.setAttribute('data-ezoic-wrapid', String(id));
@@ -647,6 +665,7 @@ function globalGapFixInit() {
647
665
  wrap.setAttribute('data-ezoic-pin', '1');
648
666
  }
649
667
  wrap.style.width = '100%';
668
+ try { if (wrap.tagName === 'LI') { wrap.style.listStyle = 'none'; wrap.setAttribute('data-ezoic-li','1'); } } catch(e) {}
650
669
 
651
670
  if (createPlaceholder) {
652
671
  const ph = document.createElement('div');
@@ -704,13 +723,144 @@ function globalGapFixInit() {
704
723
  }
705
724
 
706
725
  function pruneOrphanWraps(kindClass, items) {
726
+ // Topic pages can be virtualized (posts removed from DOM as you scroll).
727
+ // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
728
+ // nearby post containers, which leads to ads clustering together when scrolling back up.
729
+ // We prune only *true* orphans that are far offscreen to keep the UI stable.
730
+ if (!items || !items.length) return 0;
731
+ const itemSet = new Set(items);
732
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
733
+ let removed = 0;
734
+
735
+ const isFilled = (wrap) => {
736
+ return !!(wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
737
+ };
738
+
739
+ const hasNearbyItem = (wrap) => {
740
+ // NodeBB/skins can inject separators/spacers; be tolerant.
741
+ let prev = wrap.previousElementSibling;
742
+ for (let i = 0; i < 14 && prev; i++) {
743
+ if (itemSet.has(prev)) return true;
744
+ prev = prev.previousElementSibling;
745
+ }
746
+ let next = wrap.nextElementSibling;
747
+ for (let i = 0; i < 14 && next; i++) {
748
+ if (itemSet.has(next)) return true;
749
+ next = next.nextElementSibling;
750
+ }
751
+ return false;
752
+ };
753
+
754
+ wraps.forEach((wrap) => {
755
+ // Never prune pinned placements.
756
+ try {
757
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
758
+ } catch (e) {}
759
+
760
+ // For message/topic pages we may prune filled or empty orphans if they are far away,
761
+ // otherwise consecutive "stacks" can appear when posts are virtualized.
762
+ const isMessage = (kindClass === 'ezoic-ad-message');
763
+ if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
764
+
765
+ // Never prune a fresh wrap: it may fill late.
766
+ try {
767
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
768
+ if (created && (now() - created) < keepEmptyWrapMs()) return;
769
+ } catch (e) {}
770
+
771
+ if (hasNearbyItem(wrap)) {
772
+ try { wrap.classList && wrap.classList.remove('ez-orphan-hidden'); wrap.style && (wrap.style.display = ''); } catch (e) {}
707
773
  return;
708
774
  }
709
775
 
710
- function decluster(kindClass) {
711
- return;
776
+ // If the anchor item is no longer in the DOM (virtualized), hide the wrap so ads never "stack"
777
+ // back-to-back while scrolling. We'll recycle it when its anchor comes back.
778
+ try { wrap.classList && wrap.classList.add('ez-orphan-hidden'); wrap.style && (wrap.style.display = 'none'); } catch (e) {}
779
+
780
+ // For message ads: only release if far offscreen to avoid perceived "vanishing" during fast scroll.
781
+ if (isMessage) {
782
+ try {
783
+ const r = wrap.getBoundingClientRect();
784
+ const vh = Math.max(1, window.innerHeight || 1);
785
+ const farAbove = r.bottom < -vh * 2;
786
+ const farBelow = r.top > vh * 4;
787
+ if (!farAbove && !farBelow) return;
788
+ } catch (e) {
789
+ return;
790
+ }
712
791
  }
713
792
 
793
+ withInternalDomChange(() => releaseWrapNode(wrap));
794
+ removed++;
795
+ });
796
+
797
+ return removed;
798
+ }
799
+
800
+ function decluster(kindClass) {
801
+ // Remove "near-consecutive" wraps (keep the first). Be tolerant of spacer nodes.
802
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
803
+ if (wraps.length < 2) return 0;
804
+
805
+ const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
806
+
807
+ const isFilled = (wrap) => {
808
+ return !!(wrap && wrap.querySelector && wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
809
+ };
810
+
811
+ const isFresh = (wrap) => {
812
+ try {
813
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
814
+ return created && (now() - created) < keepEmptyWrapMs();
815
+ } catch (e) {
816
+ return false;
817
+ }
818
+ };
819
+
820
+ let removed = 0;
821
+ for (const w of wraps) {
822
+ // Never decluster pinned placements.
823
+ try {
824
+ if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
825
+ } catch (e) {}
826
+
827
+ let prev = w.previousElementSibling;
828
+ for (let i = 0; i < 3 && prev; i++) {
829
+ if (isWrap(prev)) {
830
+ // If the previous wrap is pinned, keep this one (spacing is intentional).
831
+ try {
832
+ if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
833
+ } catch (e) {}
834
+
835
+ // Never remove a wrap that is already filled; otherwise it looks like
836
+ // ads "disappear" while scrolling. Only remove the empty neighbour.
837
+ const prevFilled = isFilled(prev);
838
+ const curFilled = isFilled(w);
839
+
840
+ if (curFilled) {
841
+ // If the previous one is empty (and not fresh), drop the previous instead.
842
+ if (!prevFilled && !isFresh(prev)) {
843
+ withInternalDomChange(() => releaseWrapNode(prev));
844
+ removed++;
845
+ }
846
+ break;
847
+ }
848
+
849
+ // Current is empty.
850
+ // Don't decluster two "fresh" empty wraps — it can reduce fill on slow auctions.
851
+ // Only decluster when previous is filled, or when current is stale.
852
+ if (prevFilled || !isFresh(w)) {
853
+ withInternalDomChange(() => releaseWrapNode(w));
854
+ removed++;
855
+ }
856
+ break;
857
+ }
858
+ prev = prev.previousElementSibling;
859
+ }
860
+ }
861
+ return removed;
862
+ }
863
+
714
864
  // ---------------- show (preload / fast fill) ----------------
715
865
 
716
866
  function ensurePreloadObserver() {
@@ -1341,263 +1491,3 @@ function buildOrdinalMap(items) {
1341
1491
  insertHeroAdEarly().catch(() => {});
1342
1492
  requestBurst();
1343
1493
  })();
1344
-
1345
-
1346
-
1347
-
1348
-
1349
-
1350
-
1351
-
1352
- // ===== CLEAN REFRACTOR: visibility manager for Ezoic wraps =====
1353
- (function () {
1354
- // v2.6 (slot-recycler):
1355
- // Observed:
1356
- // - When scrolling up, ad wraps accumulate before first post.
1357
- // - Ezoic logs: "Placeholder Id X already defined" + "No valid placeholders for loadMore"
1358
- //
1359
- // Root cause:
1360
- // - Infinite scroll / templating introduces MULTIPLE instances of the SAME placeholder id in DOM.
1361
- // - Ezoic expects each placeholder id to be unique on the page. Duplicates cause warnings and loadMore failure.
1362
- // - Some scripts then "re-home" the active slot near the top, causing pile-up.
1363
- //
1364
- // Fix strategy:
1365
- // - Keep EXACTLY ONE DOM node per placeholder id ("canonical wrap").
1366
- // - Replace any duplicate wraps with lightweight SLOT ANCHORS left in place.
1367
- // - As user scrolls, MOVE the canonical wrap to the closest visible anchor for that id.
1368
- // (Move, not recreate => no re-define; prevents pile-up; ad follows the viewport.)
1369
- //
1370
- // We only handle .nodebb-ezoic-wrap.* nodes; we do not change post markup.
1371
-
1372
- var WRAP_SELECTOR = '.nodebb-ezoic-wrap';
1373
- var PLACEHOLDER_SELECTOR = '[data-ezoic-id]';
1374
-
1375
- // show tuning
1376
- var MAX_SHOW_PER_TICK = 3;
1377
-
1378
- // recycler tuning
1379
- var ROOT_MARGIN = 1400; // how far from viewport we consider an anchor "active"
1380
- var MOVE_COOLDOWN_MS = 120; // avoid moving too often
1381
- var SCAN_COOLDOWN_MS = 150; // throttle scroll scans
1382
- var lastScan = 0;
1383
- var lastMove = 0;
1384
-
1385
- // state
1386
- var canonicalById = Object.create(null); // id -> element
1387
- var activatedById = Object.create(null); // id -> ts
1388
- var showQueue = [];
1389
- var showTicking = false;
1390
-
1391
- function getId(w) {
1392
- try {
1393
- var id = w.getAttribute('data-ezoic-wrapid');
1394
- if (id) return id;
1395
- var ph = w.querySelector(PLACEHOLDER_SELECTOR);
1396
- if (ph) return ph.getAttribute('data-ezoic-id');
1397
- } catch (e) {}
1398
- return null;
1399
- }
1400
-
1401
- function isFilled(w) {
1402
- try {
1403
- if (w.querySelector('iframe')) return true;
1404
- if (w.querySelector('[id^="google_ads_iframe"]')) return true;
1405
- if (w.querySelector('.ezoic-ad')) return true;
1406
- } catch (e) {}
1407
- return false;
1408
- }
1409
-
1410
- function enqueueShow(id) {
1411
- if (!id) return;
1412
- if (activatedById[id]) return;
1413
- for (var i = 0; i < showQueue.length; i++) if (showQueue[i] === id) return;
1414
- showQueue.push(id);
1415
- scheduleShowTick();
1416
- }
1417
-
1418
- function scheduleShowTick() {
1419
- if (showTicking) return;
1420
- showTicking = true;
1421
- requestAnimationFrame(function () {
1422
- showTicking = false;
1423
- var n = 0;
1424
- while (showQueue.length && n < MAX_SHOW_PER_TICK) {
1425
- var id = showQueue.shift();
1426
- try {
1427
- if (!activatedById[id] && window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
1428
- window.ezstandalone.showAds(String(id));
1429
- activatedById[id] = Date.now();
1430
- }
1431
- } catch (e) {}
1432
- n++;
1433
- }
1434
- if (showQueue.length) scheduleShowTick();
1435
- });
1436
- }
1437
-
1438
- function createAnchorForDuplicate(w, id) {
1439
- try {
1440
- var a = document.createElement('span');
1441
- a.className = 'nodebb-ezoic-slot-anchor';
1442
- a.setAttribute('data-slot-for', String(id));
1443
- // keep after/index metadata if present
1444
- var after = w.getAttribute('data-ezoic-after');
1445
- if (after) a.setAttribute('data-ezoic-after', after);
1446
- a.style.display = 'block';
1447
- a.style.width = '100%';
1448
- // leave minimal height to keep spacing similar without large blank
1449
- a.style.minHeight = '1px';
1450
- w.parentNode.insertBefore(a, w);
1451
- w.remove();
1452
- } catch (e) {}
1453
- }
1454
-
1455
- function normalizeDomOnce() {
1456
- // Build canonical map; convert duplicates to anchors.
1457
- try {
1458
- var wraps = document.querySelectorAll(WRAP_SELECTOR);
1459
- for (var i = 0; i < wraps.length; i++) {
1460
- var w = wraps[i];
1461
- var id = getId(w);
1462
- if (!id) continue;
1463
-
1464
- if (!canonicalById[id]) {
1465
- canonicalById[id] = w;
1466
- // if already filled, mark activated
1467
- if (isFilled(w)) activatedById[id] = activatedById[id] || Date.now();
1468
- } else if (canonicalById[id] !== w) {
1469
- createAnchorForDuplicate(w, id);
1470
- }
1471
- }
1472
- } catch (e) {}
1473
- }
1474
-
1475
- function getCandidateAnchors(id) {
1476
- try {
1477
- return document.querySelectorAll('.nodebb-ezoic-slot-anchor[data-slot-for="' + String(id) + '"]');
1478
- } catch (e) {
1479
- return [];
1480
- }
1481
- }
1482
-
1483
- function anchorDistanceToViewport(a, vh) {
1484
- try {
1485
- var r = a.getBoundingClientRect();
1486
- // distance 0 if overlaps extended viewport, else min distance
1487
- if (r.bottom >= -ROOT_MARGIN && r.top <= (vh + ROOT_MARGIN)) return 0;
1488
- if (r.top > (vh + ROOT_MARGIN)) return r.top - (vh + ROOT_MARGIN);
1489
- if (r.bottom < -ROOT_MARGIN) return (-ROOT_MARGIN) - r.bottom;
1490
- } catch (e) {}
1491
- return 1e12;
1492
- }
1493
-
1494
- function moveCanonicalToAnchor(id, anchor) {
1495
- var now = Date.now();
1496
- if (now - lastMove < MOVE_COOLDOWN_MS) return;
1497
- lastMove = now;
1498
-
1499
- try {
1500
- var w = canonicalById[id];
1501
- if (!w || !anchor || !anchor.parentNode) return;
1502
-
1503
- // If already directly after anchor, nothing to do.
1504
- if (w.parentNode === anchor.parentNode && w.previousSibling === anchor) return;
1505
-
1506
- // Insert canonical wrap right after anchor.
1507
- if (anchor.nextSibling) anchor.parentNode.insertBefore(w, anchor.nextSibling);
1508
- else anchor.parentNode.appendChild(w);
1509
- } catch (e) {}
1510
- }
1511
-
1512
- function recycleTick() {
1513
- var now = Date.now();
1514
- if (now - lastScan < SCAN_COOLDOWN_MS) return;
1515
- lastScan = now;
1516
-
1517
- // ensure dom normalized before recycling
1518
- normalizeDomOnce();
1519
-
1520
- var vh = window.innerHeight || document.documentElement.clientHeight || 0;
1521
- if (!vh) return;
1522
-
1523
- try {
1524
- for (var id in canonicalById) {
1525
- if (!canonicalById[id]) continue;
1526
- var anchors = getCandidateAnchors(id);
1527
- if (!anchors || !anchors.length) continue;
1528
-
1529
- // choose the first anchor that intersects extended viewport, otherwise closest
1530
- var best = null;
1531
- var bestDist = 1e12;
1532
- for (var i = 0; i < anchors.length; i++) {
1533
- var a = anchors[i];
1534
- var d = anchorDistanceToViewport(a, vh);
1535
- if (d === 0) { best = a; bestDist = 0; break; }
1536
- if (d < bestDist) { bestDist = d; best = a; }
1537
- }
1538
- if (best) {
1539
- moveCanonicalToAnchor(id, best);
1540
-
1541
- // trigger show when canonical is near viewport and still empty
1542
- var w = canonicalById[id];
1543
- if (w) {
1544
- try {
1545
- var r = w.getBoundingClientRect();
1546
- if (r.bottom >= -ROOT_MARGIN && r.top <= (vh + ROOT_MARGIN)) {
1547
- if (isFilled(w)) activatedById[id] = activatedById[id] || Date.now();
1548
- else enqueueShow(id);
1549
- }
1550
- } catch (e) {}
1551
- }
1552
- }
1553
- }
1554
- } catch (e) {}
1555
-
1556
- scheduleShowTick();
1557
- }
1558
-
1559
- // Observe new content; normalize duplicates immediately
1560
- var moInstalled = false;
1561
- function installMO() {
1562
- if (moInstalled || typeof MutationObserver === 'undefined') return;
1563
- moInstalled = true;
1564
-
1565
- var mo = new MutationObserver(function (muts) {
1566
- // When new nodes arrive, normalize and run one recycle tick.
1567
- normalizeDomOnce();
1568
- recycleTick();
1569
- });
1570
-
1571
- try { mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); } catch (e) {}
1572
- }
1573
-
1574
- function init() {
1575
- normalizeDomOnce();
1576
- installMO();
1577
-
1578
- window.addEventListener('scroll', recycleTick, { passive: true });
1579
- window.addEventListener('resize', recycleTick, { passive: true });
1580
-
1581
- if (window.jQuery) {
1582
- window.jQuery(window).on('action:ajaxify.end action:infiniteScroll.loaded', function () {
1583
- setTimeout(function () {
1584
- normalizeDomOnce();
1585
- recycleTick();
1586
- }, 0);
1587
- });
1588
- }
1589
-
1590
- setInterval(recycleTick, 900);
1591
- setTimeout(recycleTick, 0);
1592
- }
1593
-
1594
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
1595
- else init();
1596
- })();
1597
- // ===== /CLEAN REFRACTOR =====
1598
-
1599
-
1600
-
1601
-
1602
-
1603
-
package/public/style.css CHANGED
@@ -81,7 +81,7 @@
81
81
  }
82
82
 
83
83
 
84
- /* ===== CLEAN REFRACTOR perf notes ===== */
85
- .nodebb-ezoic-wrap { contain: content; }
86
- /* ===== /CLEAN REFRACTOR ===== */
84
+ /* ===== V12 between-as-li ===== */
85
+ li.nodebb-ezoic-wrap.ezoic-ad-between { list-style: none; width: 100%; }
86
+ /* ===== /V12 ===== */
87
87