nodebb-plugin-ezoic-infinite 1.6.9 → 1.6.11

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.9",
3
+ "version": "1.6.11",
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,37 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
+ function createWrapHostAndWrap(kindClass, targetEl) {
5
+ // For <ul>/<ol> we MUST insert a <li> to keep valid DOM.
6
+ // But for compatibility with existing selectors (often 'div.nodebb-ezoic-wrap'),
7
+ // we keep the actual wrap as a DIV inside the LI.
8
+ var host = null;
9
+ try {
10
+ if (kindClass === 'ezoic-ad-between') {
11
+ var p = targetEl && targetEl.parentElement;
12
+ if (p && (p.tagName === 'UL' || p.tagName === 'OL')) {
13
+ host = document.createElement('li');
14
+ host.className = 'nodebb-ezoic-host';
15
+ host.setAttribute('role', 'listitem');
16
+ host.style.listStyle = 'none';
17
+ host.style.width = '100%';
18
+ }
19
+ }
20
+ } catch (e) {}
21
+
22
+ var pair = createWrapHostAndWrap(kindClass, el);
23
+ var host = pair.hostEl;
24
+ var wrap = pair.wrapEl;
25
+ wrap.className = 'nodebb-ezoic-wrap ' + kindClass;
26
+
27
+ if (host) {
28
+ host.appendChild(wrap);
29
+ return { hostEl: host, wrapEl: wrap };
30
+ }
31
+ return { hostEl: wrap, wrapEl: wrap };
32
+ }
33
+
34
+
4
35
  // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
5
36
  // Recycling while scrolling up is a common cause of ads "bunching" and a "disappearing too fast" feeling.
6
37
  let lastScrollY = 0;
@@ -637,7 +668,9 @@ function globalGapFixInit() {
637
668
  // ---------------- insertion primitives ----------------
638
669
 
639
670
  function buildWrap(id, kindClass, afterPos, createPlaceholder) {
640
- const wrap = document.createElement('div');
671
+ const pair = createWrapHostAndWrap(kindClass, el);
672
+ const host = pair.hostEl;
673
+ const wrap = pair.wrapEl;
641
674
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
642
675
  wrap.setAttribute('data-ezoic-after', String(afterPos));
643
676
  wrap.setAttribute('data-ezoic-wrapid', String(id));
@@ -704,13 +737,144 @@ function globalGapFixInit() {
704
737
  }
705
738
 
706
739
  function pruneOrphanWraps(kindClass, items) {
740
+ // Topic pages can be virtualized (posts removed from DOM as you scroll).
741
+ // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
742
+ // nearby post containers, which leads to ads clustering together when scrolling back up.
743
+ // We prune only *true* orphans that are far offscreen to keep the UI stable.
744
+ if (!items || !items.length) return 0;
745
+ const itemSet = new Set(items);
746
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
747
+ let removed = 0;
748
+
749
+ const isFilled = (wrap) => {
750
+ return !!(wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
751
+ };
752
+
753
+ const hasNearbyItem = (wrap) => {
754
+ // NodeBB/skins can inject separators/spacers; be tolerant.
755
+ let prev = wrap.previousElementSibling;
756
+ for (let i = 0; i < 14 && prev; i++) {
757
+ if (itemSet.has(prev)) return true;
758
+ prev = prev.previousElementSibling;
759
+ }
760
+ let next = wrap.nextElementSibling;
761
+ for (let i = 0; i < 14 && next; i++) {
762
+ if (itemSet.has(next)) return true;
763
+ next = next.nextElementSibling;
764
+ }
765
+ return false;
766
+ };
767
+
768
+ wraps.forEach((wrap) => {
769
+ // Never prune pinned placements.
770
+ try {
771
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
772
+ } catch (e) {}
773
+
774
+ // For message/topic pages we may prune filled or empty orphans if they are far away,
775
+ // otherwise consecutive "stacks" can appear when posts are virtualized.
776
+ const isMessage = (kindClass === 'ezoic-ad-message');
777
+ if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
778
+
779
+ // Never prune a fresh wrap: it may fill late.
780
+ try {
781
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
782
+ if (created && (now() - created) < keepEmptyWrapMs()) return;
783
+ } catch (e) {}
784
+
785
+ if (hasNearbyItem(wrap)) {
786
+ try { wrap.classList && wrap.classList.remove('ez-orphan-hidden'); wrap.style && (wrap.style.display = ''); } catch (e) {}
707
787
  return;
708
788
  }
709
789
 
710
- function decluster(kindClass) {
711
- return;
790
+ // If the anchor item is no longer in the DOM (virtualized), hide the wrap so ads never "stack"
791
+ // back-to-back while scrolling. We'll recycle it when its anchor comes back.
792
+ try { wrap.classList && wrap.classList.add('ez-orphan-hidden'); wrap.style && (wrap.style.display = 'none'); } catch (e) {}
793
+
794
+ // For message ads: only release if far offscreen to avoid perceived "vanishing" during fast scroll.
795
+ if (isMessage) {
796
+ try {
797
+ const r = wrap.getBoundingClientRect();
798
+ const vh = Math.max(1, window.innerHeight || 1);
799
+ const farAbove = r.bottom < -vh * 2;
800
+ const farBelow = r.top > vh * 4;
801
+ if (!farAbove && !farBelow) return;
802
+ } catch (e) {
803
+ return;
804
+ }
712
805
  }
713
806
 
807
+ withInternalDomChange(() => releaseWrapNode(wrap));
808
+ removed++;
809
+ });
810
+
811
+ return removed;
812
+ }
813
+
814
+ function decluster(kindClass) {
815
+ // Remove "near-consecutive" wraps (keep the first). Be tolerant of spacer nodes.
816
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
817
+ if (wraps.length < 2) return 0;
818
+
819
+ const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
820
+
821
+ const isFilled = (wrap) => {
822
+ return !!(wrap && wrap.querySelector && wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
823
+ };
824
+
825
+ const isFresh = (wrap) => {
826
+ try {
827
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
828
+ return created && (now() - created) < keepEmptyWrapMs();
829
+ } catch (e) {
830
+ return false;
831
+ }
832
+ };
833
+
834
+ let removed = 0;
835
+ for (const w of wraps) {
836
+ // Never decluster pinned placements.
837
+ try {
838
+ if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
839
+ } catch (e) {}
840
+
841
+ let prev = w.previousElementSibling;
842
+ for (let i = 0; i < 3 && prev; i++) {
843
+ if (isWrap(prev)) {
844
+ // If the previous wrap is pinned, keep this one (spacing is intentional).
845
+ try {
846
+ if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
847
+ } catch (e) {}
848
+
849
+ // Never remove a wrap that is already filled; otherwise it looks like
850
+ // ads "disappear" while scrolling. Only remove the empty neighbour.
851
+ const prevFilled = isFilled(prev);
852
+ const curFilled = isFilled(w);
853
+
854
+ if (curFilled) {
855
+ // If the previous one is empty (and not fresh), drop the previous instead.
856
+ if (!prevFilled && !isFresh(prev)) {
857
+ withInternalDomChange(() => releaseWrapNode(prev));
858
+ removed++;
859
+ }
860
+ break;
861
+ }
862
+
863
+ // Current is empty.
864
+ // Don't decluster two "fresh" empty wraps — it can reduce fill on slow auctions.
865
+ // Only decluster when previous is filled, or when current is stale.
866
+ if (prevFilled || !isFresh(w)) {
867
+ withInternalDomChange(() => releaseWrapNode(w));
868
+ removed++;
869
+ }
870
+ break;
871
+ }
872
+ prev = prev.previousElementSibling;
873
+ }
874
+ }
875
+ return removed;
876
+ }
877
+
714
878
  // ---------------- show (preload / fast fill) ----------------
715
879
 
716
880
  function ensurePreloadObserver() {
@@ -1341,263 +1505,3 @@ function buildOrdinalMap(items) {
1341
1505
  insertHeroAdEarly().catch(() => {});
1342
1506
  requestBurst();
1343
1507
  })();
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,9 @@
81
81
  }
82
82
 
83
83
 
84
- /* ===== CLEAN REFRACTOR perf notes ===== */
85
- .nodebb-ezoic-wrap { contain: content; }
86
- /* ===== /CLEAN REFRACTOR ===== */
84
+ /* ===== V12.1 li host wrapper ===== */
85
+ li.nodebb-ezoic-host { list-style: none; width: 100%; }
86
+ /* keep inner wrap full width */
87
+ li.nodebb-ezoic-host > .nodebb-ezoic-wrap { width: 100%; }
88
+ /* ===== /V12.1 ===== */
87
89