nodebb-plugin-ezoic-infinite 1.6.6 → 1.6.8

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +140 -200
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.6",
3
+ "version": "1.6.8",
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
@@ -1351,45 +1351,52 @@ function buildOrdinalMap(items) {
1351
1351
 
1352
1352
  // ===== CLEAN REFRACTOR: visibility manager for Ezoic wraps =====
1353
1353
  (function () {
1354
- // v2.4 (anchor-guard):
1355
- // Issue reported: when scrolling back up, ad wraps accumulate before the first post.
1356
- // Root cause (most likely): some script/virtualization detaches ad nodes from their original place and
1357
- // re-inserts them near the top, without their original context.
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
1358
  //
1359
- // Fix:
1360
- // - On creation of each wrap, insert a hidden "anchor" node right before it and tag the wrap with that anchor id.
1361
- // - Periodically validate: if a wrap is no longer immediately preceded by its anchor, remove the wrap.
1362
- // (We do NOT recreate here; the normal injector will insert correct wraps as needed.)
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
1363
  //
1364
- // We also keep v2.3 no-redefine rules:
1365
- // - no wrap deletion for cleanup except for misplaced wraps / duplicate placeholders
1366
- // - showAds only once per placeholder id
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.
1367
1371
 
1368
- var BETWEEN_SELECTOR = '.nodebb-ezoic-wrap.ezoic-ad-between';
1369
- var MESSAGE_SELECTOR = '.nodebb-ezoic-wrap.ezoic-ad-message';
1370
- var WRAP_SELECTOR = BETWEEN_SELECTOR + ', ' + MESSAGE_SELECTOR;
1372
+ var WRAP_SELECTOR = '.nodebb-ezoic-wrap';
1373
+ var PLACEHOLDER_SELECTOR = '[data-ezoic-id]';
1371
1374
 
1372
- var MAX_SHOW_PER_TICK = 4;
1375
+ // show tuning
1376
+ var MAX_SHOW_PER_TICK = 3;
1373
1377
 
1374
- // validation throttles
1375
- var VALIDATE_COOLDOWN_MS = 260;
1376
- var lastValidate = 0;
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;
1377
1384
 
1378
- // internal state
1379
- var activatedById = Object.create(null);
1385
+ // state
1386
+ var canonicalById = Object.create(null); // id -> element
1387
+ var activatedById = Object.create(null); // id -> ts
1380
1388
  var showQueue = [];
1381
1389
  var showTicking = false;
1382
1390
 
1383
- function getIdFromWrap(w) {
1384
- try { return w.getAttribute('data-ezoic-wrapid'); } catch (e) { return null; }
1385
- }
1386
- function getIdFromPlaceholder(w) {
1391
+ function getId(w) {
1387
1392
  try {
1388
- var ph = w.querySelector('[data-ezoic-id]');
1389
- return ph ? ph.getAttribute('data-ezoic-id') : null;
1390
- } catch (e) { return null; }
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;
1391
1399
  }
1392
- function getId(w) { return getIdFromWrap(w) || getIdFromPlaceholder(w); }
1393
1400
 
1394
1401
  function isFilled(w) {
1395
1402
  try {
@@ -1428,227 +1435,160 @@ function buildOrdinalMap(items) {
1428
1435
  });
1429
1436
  }
1430
1437
 
1431
- // --- Anchor guard ---
1432
- function ensureAnchor(w) {
1438
+ function createAnchorForDuplicate(w, id) {
1433
1439
  try {
1434
- if (!w || !w.parentNode) return;
1435
- if (w.getAttribute('data-ezoic-anchored') === '1') return;
1436
-
1437
- var wrapUid = w.getAttribute('data-ezoic-wrapuid');
1438
- if (!wrapUid) {
1439
- wrapUid = String(Date.now()) + '-' + Math.floor(Math.random() * 1e9);
1440
- w.setAttribute('data-ezoic-wrapuid', wrapUid);
1441
- }
1442
-
1443
- // If already preceded by matching anchor, mark anchored.
1444
- var prev = w.previousSibling;
1445
- if (prev && prev.nodeType === 1 && prev.classList && prev.classList.contains('nodebb-ezoic-anchor')) {
1446
- if (prev.getAttribute('data-anchor-for') === wrapUid) {
1447
- w.setAttribute('data-ezoic-anchored', '1');
1448
- return;
1449
- }
1450
- }
1451
-
1452
- // Create anchor node
1453
1440
  var a = document.createElement('span');
1454
- a.className = 'nodebb-ezoic-anchor';
1455
- a.setAttribute('data-anchor-for', wrapUid);
1456
- a.style.display = 'none';
1457
-
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';
1458
1450
  w.parentNode.insertBefore(a, w);
1459
- w.setAttribute('data-ezoic-anchored', '1');
1451
+ w.remove();
1460
1452
  } catch (e) {}
1461
1453
  }
1462
1454
 
1463
- function validateAnchors() {
1464
- var now = Date.now();
1465
- if (now - lastValidate < VALIDATE_COOLDOWN_MS) return;
1466
- lastValidate = now;
1467
-
1455
+ function normalizeDomOnce() {
1456
+ // Build canonical map; convert duplicates to anchors.
1468
1457
  try {
1469
- // Remove wraps that lost their anchor adjacency (they have been moved)
1470
- var wraps = document.querySelectorAll('.nodebb-ezoic-wrap');
1458
+ var wraps = document.querySelectorAll(WRAP_SELECTOR);
1471
1459
  for (var i = 0; i < wraps.length; i++) {
1472
1460
  var w = wraps[i];
1473
- try {
1474
- var uid = w.getAttribute('data-ezoic-wrapuid');
1475
- if (!uid) {
1476
- // If it's an old wrap with no uid, anchor it once.
1477
- ensureAnchor(w);
1478
- continue;
1479
- }
1480
- var prev = w.previousSibling;
1481
- if (!(prev && prev.nodeType === 1 && prev.classList && prev.classList.contains('nodebb-ezoic-anchor') && prev.getAttribute('data-anchor-for') === uid)) {
1482
- // Misplaced wrap -> remove it (prevents pile-up before first post)
1483
- w.remove();
1484
- }
1485
- } catch (e) {}
1486
- }
1461
+ var id = getId(w);
1462
+ if (!id) continue;
1487
1463
 
1488
- // Remove orphan anchors (anchor without following wrap)
1489
- var anchors = document.querySelectorAll('.nodebb-ezoic-anchor');
1490
- for (var j = 0; j < anchors.length; j++) {
1491
- var a = anchors[j];
1492
- try {
1493
- var next = a.nextSibling;
1494
- if (!(next && next.nodeType === 1 && next.classList && next.classList.contains('nodebb-ezoic-wrap'))) {
1495
- a.remove();
1496
- }
1497
- } catch (e) {}
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
+ }
1498
1471
  }
1499
1472
  } catch (e) {}
1500
1473
  }
1501
1474
 
1502
- // --- De-dupe placeholders to avoid Ezoic warnings ---
1503
- function dedupePlaceholders() {
1504
- var seen = Object.create(null);
1475
+ function getCandidateAnchors(id) {
1505
1476
  try {
1506
- document.querySelectorAll('.nodebb-ezoic-wrap [data-ezoic-id]').forEach(function (ph) {
1507
- try {
1508
- var id = ph.getAttribute('data-ezoic-id');
1509
- if (!id) return;
1510
- var wrap = ph.closest('.nodebb-ezoic-wrap');
1511
- if (!wrap) return;
1512
-
1513
- // ensure anchor for any wrap we touch
1514
- ensureAnchor(wrap);
1477
+ return document.querySelectorAll('.nodebb-ezoic-slot-anchor[data-slot-for="' + String(id) + '"]');
1478
+ } catch (e) {
1479
+ return [];
1480
+ }
1481
+ }
1515
1482
 
1516
- if (seen[id]) {
1517
- // remove duplicates; keep first occurrence
1518
- wrap.remove();
1519
- } else {
1520
- seen[id] = true;
1521
- }
1522
- } catch (e) {}
1523
- });
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;
1524
1490
  } catch (e) {}
1491
+ return 1e12;
1525
1492
  }
1526
1493
 
1527
- // --- Visibility: trigger show once if empty ---
1528
- var io = null;
1529
- function installIO() {
1530
- if (io || typeof IntersectionObserver === 'undefined') return;
1494
+ function moveCanonicalToAnchor(id, anchor) {
1495
+ var now = Date.now();
1496
+ if (now - lastMove < MOVE_COOLDOWN_MS) return;
1497
+ lastMove = now;
1531
1498
 
1532
- io = new IntersectionObserver(function (entries) {
1533
- try {
1534
- entries.forEach(function (e) {
1535
- if (!e || !e.target) return;
1499
+ try {
1500
+ var w = canonicalById[id];
1501
+ if (!w || !anchor || !anchor.parentNode) return;
1536
1502
 
1537
- // Always ensure anchor while visible/near
1538
- ensureAnchor(e.target);
1503
+ // If already directly after anchor, nothing to do.
1504
+ if (w.parentNode === anchor.parentNode && w.previousSibling === anchor) return;
1539
1505
 
1540
- if (e.isIntersecting) {
1541
- var id = getId(e.target);
1542
- if (!id) return;
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
+ }
1543
1511
 
1544
- if (isFilled(e.target)) {
1545
- activatedById[id] = activatedById[id] || Date.now();
1546
- return;
1547
- }
1548
- enqueueShow(id);
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) {}
1549
1551
  }
1550
- });
1551
- } catch (e) {}
1552
- }, { root: null, rootMargin: '1200px 0px 1200px 0px', threshold: 0.01 });
1552
+ }
1553
+ }
1554
+ } catch (e) {}
1553
1555
 
1554
- try { document.querySelectorAll(WRAP_SELECTOR).forEach(function (w) { try { ensureAnchor(w); io.observe(w); } catch(e) {} }); } catch (e) {}
1556
+ scheduleShowTick();
1555
1557
  }
1556
1558
 
1557
- // Observe newly added wraps and anchor them
1559
+ // Observe new content; normalize duplicates immediately
1558
1560
  var moInstalled = false;
1559
1561
  function installMO() {
1560
1562
  if (moInstalled || typeof MutationObserver === 'undefined') return;
1561
1563
  moInstalled = true;
1562
1564
 
1563
1565
  var mo = new MutationObserver(function (muts) {
1564
- try { dedupePlaceholders(); } catch (e) {}
1565
-
1566
- if (!io) return;
1567
- try {
1568
- for (var i = 0; i < muts.length; i++) {
1569
- var m = muts[i];
1570
- if (!m.addedNodes) continue;
1571
- for (var j = 0; j < m.addedNodes.length; j++) {
1572
- var n = m.addedNodes[j];
1573
- if (!n || n.nodeType !== 1) continue;
1574
-
1575
- if (n.matches && n.matches(WRAP_SELECTOR)) {
1576
- ensureAnchor(n);
1577
- try { io.observe(n); } catch (e) {}
1578
- var id = getId(n);
1579
- if (id && !isFilled(n)) enqueueShow(id);
1580
- else if (id) activatedById[id] = activatedById[id] || Date.now();
1581
- } else if (n.querySelectorAll) {
1582
- var inner = n.querySelectorAll(WRAP_SELECTOR);
1583
- for (var k = 0; k < inner.length; k++) {
1584
- ensureAnchor(inner[k]);
1585
- try { io.observe(inner[k]); } catch (e) {}
1586
- }
1587
- }
1588
- }
1589
- }
1590
- } catch (e) {}
1566
+ // When new nodes arrive, normalize and run one recycle tick.
1567
+ normalizeDomOnce();
1568
+ recycleTick();
1591
1569
  });
1592
1570
 
1593
1571
  try { mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); } catch (e) {}
1594
1572
  }
1595
1573
 
1596
1574
  function init() {
1597
- // anchor + dedupe existing
1598
- try {
1599
- document.querySelectorAll('.nodebb-ezoic-wrap').forEach(function (w) { ensureAnchor(w); });
1600
- } catch (e) {}
1601
- dedupePlaceholders();
1602
-
1603
- installIO();
1575
+ normalizeDomOnce();
1604
1576
  installMO();
1605
1577
 
1606
- // initial eager show for empty visible-ish wraps (bounded)
1607
- try {
1608
- var vh = window.innerHeight || document.documentElement.clientHeight || 0;
1609
- var margin = 900;
1610
- var wraps = document.querySelectorAll(WRAP_SELECTOR);
1611
- var budget = 12;
1612
- for (var i = 0; i < wraps.length && budget > 0; i++) {
1613
- var w = wraps[i];
1614
- ensureAnchor(w);
1615
- var r = w.getBoundingClientRect();
1616
- if (r.bottom >= -margin && r.top <= (vh + margin)) {
1617
- var id = getId(w);
1618
- if (id) {
1619
- if (isFilled(w)) activatedById[id] = activatedById[id] || Date.now();
1620
- else enqueueShow(id);
1621
- }
1622
- budget--;
1623
- }
1624
- }
1625
- } catch (e) {}
1626
-
1627
- // validate regularly on scroll/resize (cheap)
1628
- window.addEventListener('scroll', function () {
1629
- validateAnchors();
1630
- scheduleShowTick();
1631
- }, { passive: true });
1632
- window.addEventListener('resize', function () {
1633
- validateAnchors();
1634
- scheduleShowTick();
1635
- }, { passive: true });
1578
+ window.addEventListener('scroll', recycleTick, { passive: true });
1579
+ window.addEventListener('resize', recycleTick, { passive: true });
1636
1580
 
1637
1581
  if (window.jQuery) {
1638
1582
  window.jQuery(window).on('action:ajaxify.end action:infiniteScroll.loaded', function () {
1639
1583
  setTimeout(function () {
1640
- try { document.querySelectorAll('.nodebb-ezoic-wrap').forEach(function (w) { ensureAnchor(w); }); } catch (e) {}
1641
- dedupePlaceholders();
1642
- validateAnchors();
1643
- scheduleShowTick();
1584
+ normalizeDomOnce();
1585
+ recycleTick();
1644
1586
  }, 0);
1645
1587
  });
1646
1588
  }
1647
1589
 
1648
- // periodic validate safety net
1649
- setInterval(validateAnchors, 1200);
1650
-
1651
- setTimeout(function () { validateAnchors(); scheduleShowTick(); }, 0);
1590
+ setInterval(recycleTick, 900);
1591
+ setTimeout(recycleTick, 0);
1652
1592
  }
1653
1593
 
1654
1594
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);