nodebb-plugin-ezoic-infinite 1.5.4 → 1.5.5

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/library.js CHANGED
@@ -68,7 +68,7 @@ async function getSettings() {
68
68
  async function isUserExcluded(uid, excludedGroups) {
69
69
  if (!uid || !excludedGroups.length) return false;
70
70
  const userGroups = await groups.getUserGroups([uid]);
71
- return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : String(g)));
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : g));
72
72
  }
73
73
 
74
74
  plugin.onSettingsSet = function (data) {
@@ -107,11 +107,10 @@ plugin.init = async ({ router, middleware }) => {
107
107
 
108
108
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
109
  const settings = await getSettings();
110
- const excluded = await isUserExcluded((req.uid || (req.user && req.user.uid)), settings.excludedGroups);
110
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
111
111
 
112
112
  res.json({
113
113
  excluded,
114
- excludedGroups: settings.excludedGroups,
115
114
  enableBetweenAds: settings.enableBetweenAds,
116
115
  showFirstTopicAd: settings.showFirstTopicAd,
117
116
  placeholderIds: settings.placeholderIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
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,369 +1,609 @@
1
- 'use strict';
2
- /**
3
- * Ezoic Infinite Ads for NodeBB 4.x
4
- * - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
5
- * - Hard gate on server config (respects excluded groups BEFORE any injection)
6
- * - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
7
- * - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
8
- */
9
-
10
1
  (function () {
11
- const CFG_URL = '/api/plugins/ezoic-infinite/config';
2
+ 'use strict';
12
3
 
13
- // Ensure Ezoic globals exist early (prevents `cmd`/`_ezaq` crashes)
14
- function bootEzGlobals(){
15
- window._ezaq = window._ezaq || [];
16
- window.ezstandalone = window.ezstandalone || {};
17
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
18
- }
4
+ // Safety stubs (do NOT stub showAds)
5
+ window._ezaq = window._ezaq || [];
6
+ window.ezstandalone = window.ezstandalone || {};
7
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
8
+
9
+ // NodeBB client context
10
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
19
11
 
20
- bootEzGlobals();
12
+ const WRAP_CLASS = 'ezoic-ad';
13
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
21
14
 
22
- // -------------------------
23
- // Utilities
24
- // -------------------------
25
- const now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
26
- const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
15
+ // Insert at most N ads per run to keep the UI smooth on infinite scroll
16
+ const MAX_INSERTS_PER_RUN = 3;
27
17
 
28
- function on(ev, fn) { document.addEventListener(ev, fn); }
29
- function once(ev, fn) {
30
- const h = (e) => { document.removeEventListener(ev, h); fn(e); };
31
- document.addEventListener(ev, h);
18
+ // Preload before viewport (tune if you want even earlier)
19
+ const PRELOAD_ROOT_MARGIN = '1200px 0px';
20
+
21
+ const SELECTORS = {
22
+ topicItem: 'li[component="category/topic"]',
23
+ postItem: '[component="post"][data-pid]',
24
+ categoryItem: 'li[component="categories/category"]',
25
+ };
26
+
27
+ // Hard block during navigation to avoid “placeholder does not exist” spam
28
+ let EZOIC_BLOCKED = false;
29
+
30
+ const state = {
31
+ pageKey: null,
32
+ cfg: null,
33
+
34
+ poolTopics: [],
35
+ poolPosts: [],
36
+ poolCategories: [],
37
+
38
+ usedTopics: new Set(),
39
+ usedPosts: new Set(),
40
+ usedCategories: new Set(),
41
+
42
+ // throttle per placeholder id
43
+ lastShowById: new Map(),
44
+
45
+ // observers / schedulers
46
+ domObs: null,
47
+ io: null,
48
+ runQueued: false,
49
+
50
+ // hero
51
+ heroDoneForPage: false,
52
+ };
53
+
54
+ const sessionDefinedIds = new Set();
55
+ const insertingIds = new Set();
56
+
57
+ // ---------- small utils ----------
58
+
59
+ function normalizeBool(v) {
60
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
32
61
  }
33
62
 
34
- // -------------------------
35
- // Global state / navigation token
36
- // -------------------------
37
- let navToken = 0; // increment on ajaxify.start (page is about to be replaced)
38
- let rafScheduled = false;
39
-
40
- // resolved config (or null if failed)
41
- let cfgPromise = null;
42
- let cfg = null;
43
-
44
- // -------------------------
45
- // Placeholder Registry (keeps IDs alive)
46
- // -------------------------
47
- const Pool = {
48
- el: null,
49
- ensurePool() {
50
- if (this.el && document.body.contains(this.el)) return this.el;
51
- const d = document.createElement('div');
52
- d.id = 'ezoic-placeholder-pool';
53
- d.style.display = 'none';
54
- d.setAttribute('aria-hidden', 'true');
55
- document.body.appendChild(d);
56
- this.el = d;
57
- return d;
58
- },
59
- ensurePlaceholder(id) {
60
- if (!id) return null;
61
- let ph = document.getElementById(id);
62
- if (ph) return ph;
63
- const pool = this.ensurePool();
64
- ph = document.createElement('div');
65
- ph.id = id;
66
- pool.appendChild(ph);
67
- return ph;
68
- },
69
- returnToPool(id) {
70
- const ph = document.getElementById(id);
71
- if (!ph) return;
72
- const pool = this.ensurePool();
73
- if (ph.parentElement !== pool) {
74
- pool.appendChild(ph);
63
+ function uniqInts(lines) {
64
+ const out = [];
65
+ const seen = new Set();
66
+ for (const v of lines) {
67
+ const n = parseInt(v, 10);
68
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
69
+ seen.add(n);
70
+ out.push(n);
75
71
  }
76
72
  }
77
- };
73
+ return out;
74
+ }
78
75
 
79
- // -------------------------
80
- // Ezoic showAds safe wrapper (installed even if defined later)
81
- // -------------------------
82
- function normalizeIds(argsLike) {
83
- const args = Array.from(argsLike);
84
- if (!args.length) return [];
85
- // showAds([id1,id2]) or showAds([[...]]) edge
86
- if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
87
- return args.flat().map(String);
76
+ function parsePool(raw) {
77
+ if (!raw) return [];
78
+ const lines = String(raw)
79
+ .split(/\r?\n/)
80
+ .map(s => s.trim())
81
+ .filter(Boolean);
82
+ return uniqInts(lines);
88
83
  }
89
84
 
90
- // -------------------------
91
- // Ezoic safe calls (no defineProperty hooks)
92
- // -------------------------
93
- function ensureEzGlobals() {
94
- window._ezaq = window._ezaq || [];
95
- window.ezstandalone = window.ezstandalone || {};
96
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
97
- }
85
+ function getPageKey() {
86
+ try {
87
+ const ax = window.ajaxify;
88
+ if (ax && ax.data) {
89
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
90
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
91
+ }
92
+ } catch (e) {}
93
+ return window.location.pathname;
94
+ }
98
95
 
99
- function safeShowAds(ids) {
100
- ensureEzGlobals();
101
- const show = (window.ezstandalone && typeof window.ezstandalone.showAds === 'function')
102
- ? window.ezstandalone.showAds
103
- : null;
96
+ function getKind() {
97
+ const p = window.location.pathname || '';
98
+ if (/^\/topic\//.test(p)) return 'topic';
99
+ if (/^\/category\//.test(p)) return 'categoryTopics';
100
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
101
+
102
+ // fallback by DOM
103
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
104
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
105
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
106
+ return 'other';
107
+ }
104
108
 
105
- if (!show) return;
109
+ function getTopicItems() {
110
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
111
+ }
106
112
 
107
- for (const id of ids) {
108
- Pool.ensurePlaceholder(id);
109
- const el = document.getElementById(id);
110
- if (!el) continue;
113
+ function getCategoryItems() {
114
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
115
+ }
111
116
 
112
- // Don't render into the hidden pool (would be invisible, and can confuse Ezoic)
113
- if (el.closest('#ezoic-placeholder-pool')) continue;
117
+ function getPostContainers() {
118
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
119
+ return nodes.filter((el) => {
120
+ if (!el || !el.isConnected) return false;
121
+ if (!el.querySelector('[component="post/content"]')) return false;
122
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
123
+ if (parentPost && parentPost !== el) return false;
124
+ if (el.getAttribute('component') === 'post/parent') return false;
125
+ return true;
126
+ });
127
+ }
114
128
 
115
- if (!document.body.contains(el)) continue;
129
+ // ---------- warm-up & patching ----------
130
+
131
+ const _warmLinksDone = new Set();
132
+ function warmUpNetwork() {
133
+ try {
134
+ const head = document.head || document.getElementsByTagName('head')[0];
135
+ if (!head) return;
136
+ const links = [
137
+ ['preconnect', 'https://g.ezoic.net', true],
138
+ ['dns-prefetch', 'https://g.ezoic.net', false],
139
+ ['preconnect', 'https://go.ezoic.net', true],
140
+ ['dns-prefetch', 'https://go.ezoic.net', false],
141
+ ];
142
+ for (const [rel, href, cors] of links) {
143
+ const key = `${rel}|${href}`;
144
+ if (_warmLinksDone.has(key)) continue;
145
+ _warmLinksDone.add(key);
146
+ const link = document.createElement('link');
147
+ link.rel = rel;
148
+ link.href = href;
149
+ if (cors) link.crossOrigin = 'anonymous';
150
+ head.appendChild(link);
151
+ }
152
+ } catch (e) {}
153
+ }
116
154
 
117
- try { show.call(window.ezstandalone, id); } catch (e) {}
155
+ // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
156
+ function patchShowAds() {
157
+ const applyPatch = () => {
158
+ try {
159
+ window.ezstandalone = window.ezstandalone || {};
160
+ const ez = window.ezstandalone;
161
+ if (window.__nodebbEzoicPatched) return;
162
+ if (typeof ez.showAds !== 'function') return;
163
+
164
+ window.__nodebbEzoicPatched = true;
165
+ const orig = ez.showAds;
166
+
167
+ ez.showAds = function (...args) {
168
+ if (EZOIC_BLOCKED) return;
169
+
170
+ let ids = [];
171
+ if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
172
+ else ids = args;
173
+
174
+ const seen = new Set();
175
+ for (const v of ids) {
176
+ const id = parseInt(v, 10);
177
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
178
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
179
+ if (!ph || !ph.isConnected) continue;
180
+ seen.add(id);
181
+ try { orig.call(ez, id); } catch (e) {}
182
+ }
183
+ };
184
+ } catch (e) {}
185
+ };
186
+
187
+ applyPatch();
188
+ if (!window.__nodebbEzoicPatched) {
189
+ try {
190
+ window.ezstandalone = window.ezstandalone || {};
191
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
192
+ window.ezstandalone.cmd.push(applyPatch);
193
+ } catch (e) {}
194
+ }
118
195
  }
119
- }
120
196
 
121
- function ezCmd(fn) {
197
+ // ---------- config & pools ----------
198
+
199
+ async function fetchConfigOnce() {
200
+ if (state.cfg) return state.cfg;
201
+ try {
202
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
203
+ if (!res.ok) return null;
204
+ state.cfg = await res.json();
205
+ return state.cfg;
206
+ } catch (e) {
207
+ return null;
208
+ }
209
+ }
122
210
 
123
- // Tokenize so queued callbacks don't run after navigation
124
- const tokenAtSchedule = navToken;
125
- window.ezstandalone = window.ezstandalone || {};
126
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
127
- window.ezstandalone.cmd.push(function () {
128
- if (tokenAtSchedule !== navToken) return;
129
- try { fn(); } catch (e) {}
130
- });
211
+ function initPools(cfg) {
212
+ if (!cfg) return;
213
+ if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
214
+ if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
215
+ if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
131
216
  }
132
217
 
133
- // -------------------------
134
- // Config (hard gate)
135
- // -------------------------
136
- function prefetchConfig() {
137
- if (cfgPromise) return cfgPromise;
138
-
139
- cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
140
- .then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
141
- .then(data => {
142
- cfg = data;
143
- // Pre-create placeholders in pool so Ezoic never complains even before first injection
144
- const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
145
- ids.forEach(id => Pool.ensurePlaceholder(id));
146
- return cfg;
147
- })
148
- .catch(() => {
149
- cfg = null;
150
- return null;
151
- });
218
+ // ---------- insertion primitives ----------
152
219
 
153
- return cfgPromise;
220
+ function isAdjacentAd(target) {
221
+ if (!target) return false;
222
+ const next = target.nextElementSibling;
223
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
224
+ const prev = target.previousElementSibling;
225
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
226
+ return false;
154
227
  }
155
228
 
156
- function parsePlaceholderIds(s) {
157
- return String(s || '')
158
- .split(',')
159
- .map(x => x.trim())
160
- .filter(Boolean);
229
+ function buildWrap(target, id, kindClass, afterPos) {
230
+ const tag = (target && target.tagName === 'LI') ? 'li' : 'div';
231
+ const wrap = document.createElement(tag);
232
+ if (tag === 'li') {
233
+ wrap.style.listStyle = 'none';
234
+ // preserve common NodeBB list styling
235
+ if (target && target.classList && target.classList.contains('list-group-item')) wrap.classList.add('list-group-item');
236
+ }
237
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
238
+ if (wrap.tagName === 'LI') {
239
+ wrap.setAttribute('role', 'presentation');
240
+ wrap.setAttribute('aria-hidden', 'true');
241
+ }
242
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
243
+ wrap.style.width = '100%';
244
+
245
+ const ph = document.createElement('div');
246
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
247
+ ph.setAttribute('data-ezoic-id', String(id));
248
+ wrap.appendChild(ph);
249
+
250
+ return wrap;
161
251
  }
162
252
 
163
- // -------------------------
164
- // Ad insertion logic
165
- // -------------------------
166
- const Ad = {
167
- // throttle showAds per placeholder id
168
- lastShowAt: new Map(),
169
- minShowIntervalMs: 1500,
253
+ function findWrap(kindClass, afterPos) {
254
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
255
+ }
170
256
 
171
- // Observers
172
- io: null,
173
- mo: null,
174
-
175
- // Bookkeeping
176
- insertedKeys: new Set(), // avoid duplicate injection on re-renders
177
-
178
- initObservers() {
179
- if (!this.io) {
180
- this.io = new IntersectionObserver((entries) => {
181
- for (const e of entries) {
182
- if (!e.isIntersecting) continue;
183
- const id = e.target && e.target.getAttribute('data-ezoic-id');
184
- if (id) this.requestShow(id);
185
- }
186
- }, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
187
- }
257
+ function insertAfter(target, id, kindClass, afterPos) {
258
+ if (!target || !target.insertAdjacentElement) return null;
259
+ if (findWrap(kindClass, afterPos)) return null;
260
+ if (insertingIds.has(id)) return null;
261
+
262
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
263
+ if (existingPh && existingPh.isConnected) return null;
264
+
265
+ insertingIds.add(id);
266
+ try {
267
+ const wrap = buildWrap(target, id, kindClass, afterPos);
268
+ target.insertAdjacentElement('afterend', wrap);
269
+ return wrap;
270
+ } finally {
271
+ insertingIds.delete(id);
272
+ }
273
+ }
188
274
 
189
- if (!this.mo) {
190
- this.mo = new MutationObserver(() => this.scheduleScan());
191
- const root = document.querySelector('#content, #panel, main, body');
192
- if (root) this.mo.observe(root, { childList: true, subtree: true });
193
- }
194
- },
195
-
196
- disconnectObservers() {
197
- try { this.io && this.io.disconnect(); } catch (e) {}
198
- try { this.mo && this.mo.disconnect(); } catch (e) {}
199
- this.io = null;
200
- this.mo = null;
201
- },
202
-
203
- scheduleScan() {
204
- if (rafScheduled) return;
205
- rafScheduled = true;
206
- requestAnimationFrame(() => {
207
- rafScheduled = false;
208
- this.scanAndInject();
209
- });
210
- },
211
-
212
- async scanAndInject() {
213
- const c = await prefetchConfig();
214
- if (!c) return;
215
-
216
- if (c.excluded) return; // HARD stop: never inject for excluded users
217
-
218
- this.initObservers();
219
-
220
- if (c.enableBetweenAds) this.injectBetweenTopics(c);
221
- // Extend: categories ads if you use them
222
- },
223
-
224
- // NodeBB topic list injection (between li rows)
225
- injectBetweenTopics(c) {
226
- // NodeBB 4.x (Harmony) uses: <ul component="category" class="topics-list ...">
227
- // Older themes may use .topic-list
228
- const container =
229
- document.querySelector('ul[component="category"].topics-list') ||
230
- document.querySelector('ul[component="category"].topic-list') ||
231
- document.querySelector('ul.topics-list[component="category"]') ||
232
- document.querySelector('ul.topics-list') ||
233
- document.querySelector('.category ul.topics-list') ||
234
- document.querySelector('.category .topic-list') ||
235
- document.querySelector('.topics .topic-list') ||
236
- document.querySelector('.topic-list');
237
-
238
- if (!container) return;
239
-
240
- let rows = Array.from(container.querySelectorAll(':scope > li')).filter(el => el && el.nodeType === 1);
241
- if (!rows.length) {
242
- // Fallback for older markups
243
- rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item')).filter(el => el && el.nodeType === 1);
244
- }
245
- if (!rows.length) return;
275
+ function pickId(pool) {
276
+ return pool.length ? pool.shift() : null;
277
+ }
246
278
 
247
- const ids = parsePlaceholderIds(c.placeholderIds);
248
- if (!ids.length) return;
279
+ function showAd(id) {
280
+ if (!id || EZOIC_BLOCKED) return;
249
281
 
250
- const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
282
+ const now = Date.now();
283
+ const last = state.lastShowById.get(id) || 0;
284
+ if (now - last < 1500) return; // basic throttle
251
285
 
252
- // HERO: early, above-the-fold (first eligible insertion point)
253
- if (c.showFirstTopicAd) {
254
- this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
255
- }
286
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
287
+ if (!ph || !ph.isConnected) return;
256
288
 
257
- // Between rows
258
- let idIndex = 0;
259
- for (let i = interval; i < rows.length; i += interval) {
260
- idIndex = (idIndex + 1) % ids.length;
261
- this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
262
- }
263
- },
289
+ state.lastShowById.set(id, now);
264
290
 
265
- insertAdAfterRow(rowEl, placeholderId, key) {
266
- if (!rowEl || !placeholderId || !key) return;
267
- if (this.insertedKeys.has(key)) return;
291
+ try {
292
+ window.ezstandalone = window.ezstandalone || {};
293
+ const ez = window.ezstandalone;
268
294
 
269
- // If already present nearby, skip
270
- if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
271
- this.insertedKeys.add(key);
295
+ // Fast path
296
+ if (typeof ez.showAds === 'function') {
297
+ ez.showAds(id);
298
+ sessionDefinedIds.add(id);
272
299
  return;
273
300
  }
274
301
 
275
- const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
276
- const wrapper = document.createElement(isLi ? 'li' : 'div');
277
- wrapper.className = 'ezoic-ad-wrapper';
278
- wrapper.setAttribute('role', 'presentation');
279
- wrapper.setAttribute('data-ezoic-id', placeholderId);
302
+ // Queue once for when Ezoic is ready
303
+ ez.cmd = ez.cmd || [];
304
+ if (!ph.__ezoicQueued) {
305
+ ph.__ezoicQueued = true;
306
+ ez.cmd.push(() => {
307
+ try {
308
+ if (EZOIC_BLOCKED) return;
309
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
310
+ if (!el || !el.isConnected) return;
311
+ window.ezstandalone.showAds(id);
312
+ sessionDefinedIds.add(id);
313
+ } catch (e) {}
314
+ });
315
+ }
316
+ } catch (e) {}
317
+ }
318
+
319
+ // ---------- preload / above-the-fold ----------
320
+
321
+ function ensurePreloadObserver() {
322
+ if (state.io) return state.io;
323
+ try {
324
+ state.io = new IntersectionObserver((entries) => {
325
+ for (const ent of entries) {
326
+ if (!ent.isIntersecting) continue;
327
+ const el = ent.target;
328
+ try { state.io && state.io.unobserve(el); } catch (e) {}
329
+
330
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
331
+ const id = parseInt(idAttr, 10);
332
+ if (Number.isFinite(id) && id > 0) showAd(id);
333
+ }
334
+ }, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
335
+ } catch (e) {
336
+ state.io = null;
337
+ }
338
+ return state.io;
339
+ }
340
+
341
+ function observePlaceholder(id) {
342
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
343
+ if (!ph || !ph.isConnected) return;
344
+ const io = ensurePreloadObserver();
345
+ try { io && io.observe(ph); } catch (e) {}
346
+
347
+ // If already above fold, fire immediately
348
+ try {
349
+ const r = ph.getBoundingClientRect();
350
+ if (r.top < window.innerHeight * 1.5 && r.bottom > -200) showAd(id);
351
+ } catch (e) {}
352
+ }
353
+
354
+ // ---------- insertion logic ----------
355
+
356
+ function computeTargets(count, interval, showFirst) {
357
+ const out = [];
358
+ if (count <= 0) return out;
359
+ if (showFirst) out.push(1);
360
+ for (let i = 1; i <= count; i++) {
361
+ if (i % interval === 0) out.push(i);
362
+ }
363
+ return Array.from(new Set(out)).sort((a, b) => a - b);
364
+ }
365
+
366
+ function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
367
+ if (!items.length) return 0;
368
+
369
+ const targets = computeTargets(items.length, interval, showFirst);
370
+ let inserted = 0;
371
+
372
+ for (const afterPos of targets) {
373
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
280
374
 
281
- // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
282
- if (isLi && rowEl.classList.contains('list-group-item')) {
283
- wrapper.classList.add('list-group-item');
375
+ const el = items[afterPos - 1];
376
+ if (!el || !el.isConnected) continue;
377
+ if (isAdjacentAd(el)) continue;
378
+ if (findWrap(kindClass, afterPos)) continue;
379
+
380
+ const id = pickId(pool);
381
+ if (!id) break;
382
+
383
+ usedSet.add(id);
384
+ const wrap = insertAfter(el, id, kindClass, afterPos);
385
+ if (!wrap) {
386
+ usedSet.delete(id);
387
+ pool.unshift(id);
388
+ continue;
284
389
  }
285
390
 
286
- // Ensure placeholder exists (in pool) then move it into wrapper
287
- const ph = Pool.ensurePlaceholder(placeholderId);
288
- // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
289
- try { wrapper.appendChild(ph); } catch (e) {}
391
+ observePlaceholder(id);
392
+ inserted += 1;
393
+ }
394
+
395
+ return inserted;
396
+ }
397
+
398
+ async function insertHeroAdEarly() {
399
+ if (state.heroDoneForPage) return;
400
+ const cfg = await fetchConfigOnce();
401
+ if (!cfg || cfg.excluded) return;
402
+
403
+ initPools(cfg);
404
+
405
+ const kind = getKind();
406
+ let items = [];
407
+ let pool = null;
408
+ let usedSet = null;
409
+ let kindClass = '';
410
+
411
+ if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
412
+ items = getPostContainers();
413
+ pool = state.poolPosts;
414
+ usedSet = state.usedPosts;
415
+ kindClass = 'ezoic-ad-message';
416
+ } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
417
+ items = getTopicItems();
418
+ pool = state.poolTopics;
419
+ usedSet = state.usedTopics;
420
+ kindClass = 'ezoic-ad-between';
421
+ } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
422
+ items = getCategoryItems();
423
+ pool = state.poolCategories;
424
+ usedSet = state.usedCategories;
425
+ kindClass = 'ezoic-ad-categories';
426
+ } else {
427
+ return;
428
+ }
429
+
430
+ if (!items.length) return;
431
+
432
+ // Insert after the very first item (above-the-fold)
433
+ const afterPos = 1;
434
+ const el = items[afterPos - 1];
435
+ if (!el || !el.isConnected) return;
436
+ if (isAdjacentAd(el)) return;
437
+ if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
438
+
439
+ const id = pickId(pool);
440
+ if (!id) return;
441
+
442
+ usedSet.add(id);
443
+ const wrap = insertAfter(el, id, kindClass, afterPos);
444
+ if (!wrap) {
445
+ usedSet.delete(id);
446
+ pool.unshift(id);
447
+ return;
448
+ }
449
+
450
+ state.heroDoneForPage = true;
451
+ observePlaceholder(id);
452
+ }
290
453
 
291
- // Insert into DOM
292
- rowEl.insertAdjacentElement('afterend', wrapper);
293
- this.insertedKeys.add(key);
454
+ async function runCore() {
455
+ if (EZOIC_BLOCKED) return;
294
456
 
295
- // Observe for preloading and request a show
296
- if (this.io) this.io.observe(wrapper);
457
+ patchShowAds();
297
458
 
298
- // If above fold, request immediately (no timeout)
299
- const rect = wrapper.getBoundingClientRect();
300
- if (rect.top < (window.innerHeight * 1.5)) {
301
- this.requestShow(placeholderId);
459
+ const cfg = await fetchConfigOnce();
460
+ if (!cfg || cfg.excluded) return;
461
+ initPools(cfg);
462
+
463
+ const kind = getKind();
464
+
465
+ if (kind === 'topic') {
466
+ if (normalizeBool(cfg.enableMessageAds)) {
467
+ injectBetween(
468
+ 'ezoic-ad-message',
469
+ getPostContainers(),
470
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
471
+ normalizeBool(cfg.showFirstMessageAd),
472
+ state.poolPosts,
473
+ state.usedPosts
474
+ );
302
475
  }
303
- },
304
-
305
- requestShow(placeholderId) {
306
- if (!placeholderId) return;
307
-
308
- const last = this.lastShowAt.get(placeholderId) || 0;
309
- const t = now();
310
- if ((t - last) < this.minShowIntervalMs) return;
311
- this.lastShowAt.set(placeholderId, t);
312
-
313
- // Ensure placeholder exists in DOM (pool or wrapper)
314
- Pool.ensurePlaceholder(placeholderId);
315
-
316
- // Use ez cmd queue if available
317
- const doShow = () => {
318
- if (!window.ezstandalone || !window.ezstandalone.showAds) return;
319
- // showAds is wrapped; calling with one id is safest
320
- window.ezstandalone.showAds(placeholderId);
321
- };
322
-
323
- if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
324
- ezCmd(doShow);
325
- } else {
326
- doShow();
476
+ } else if (kind === 'categoryTopics') {
477
+ if (normalizeBool(cfg.enableBetweenAds)) {
478
+ injectBetween(
479
+ 'ezoic-ad-between',
480
+ getTopicItems(),
481
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
482
+ normalizeBool(cfg.showFirstTopicAd),
483
+ state.poolTopics,
484
+ state.usedTopics
485
+ );
486
+ }
487
+ } else if (kind === 'categories') {
488
+ if (normalizeBool(cfg.enableCategoryAds)) {
489
+ injectBetween(
490
+ 'ezoic-ad-categories',
491
+ getCategoryItems(),
492
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
493
+ normalizeBool(cfg.showFirstCategoryAd),
494
+ state.poolCategories,
495
+ state.usedCategories
496
+ );
327
497
  }
328
- },
329
-
330
- // On navigation, return all placeholders to pool so they still exist
331
- reclaimPlaceholders() {
332
- const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
333
- wrappers.forEach(w => {
334
- const id = w.getAttribute('data-ezoic-id');
335
- if (id) Pool.returnToPool(id);
336
- });
337
- // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
338
- this.insertedKeys.clear();
339
- this.lastShowAt.clear();
340
498
  }
341
- };
499
+ }
500
+
501
+ function scheduleRun() {
502
+ if (state.runQueued) return;
503
+ state.runQueued = true;
504
+ window.requestAnimationFrame(() => {
505
+ state.runQueued = false;
506
+ const pk = getPageKey();
507
+ if (state.pageKey && pk !== state.pageKey) return;
508
+ runCore().catch(() => {});
509
+ });
510
+ }
511
+
512
+ // ---------- observers / lifecycle ----------
513
+
514
+ function cleanup() {
515
+ EZOIC_BLOCKED = true;
516
+
517
+ // remove all wrappers
518
+ try {
519
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
520
+ try { el.remove(); } catch (e) {}
521
+ });
522
+ } catch (e) {}
523
+
524
+ // reset state
525
+ state.cfg = null;
526
+ state.poolTopics = [];
527
+ state.poolPosts = [];
528
+ state.poolCategories = [];
529
+ state.usedTopics.clear();
530
+ state.usedPosts.clear();
531
+ state.usedCategories.clear();
532
+ state.lastShowById.clear();
533
+ state.heroDoneForPage = false;
534
+
535
+ sessionDefinedIds.clear();
536
+
537
+ // keep observers alive (MutationObserver will re-trigger after navigation)
538
+ }
342
539
 
343
- // -------------------------
344
- // NodeBB hooks
345
- // -------------------------
346
- function onAjaxifyStart() {
347
- navToken++;
348
- Ad.disconnectObservers();
349
- Ad.reclaimPlaceholders();
540
+ function ensureDomObserver() {
541
+ if (state.domObs) return;
542
+ state.domObs = new MutationObserver(() => {
543
+ if (!EZOIC_BLOCKED) scheduleRun();
544
+ });
545
+ try {
546
+ state.domObs.observe(document.body, { childList: true, subtree: true });
547
+ } catch (e) {}
548
+ }
549
+
550
+ function bindNodeBB() {
551
+ if (!$) return;
552
+
553
+ $(window).off('.ezoicInfinite');
554
+
555
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
556
+ cleanup();
557
+ });
558
+
559
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
560
+ state.pageKey = getPageKey();
561
+ EZOIC_BLOCKED = false;
562
+
563
+ warmUpNetwork();
564
+ patchShowAds();
565
+ ensurePreloadObserver();
566
+ ensureDomObserver();
567
+
568
+ // Ultra-fast above-the-fold first
569
+ insertHeroAdEarly().catch(() => {});
570
+
571
+ // Then normal insertion
572
+ scheduleRun();
573
+ });
574
+
575
+ // Infinite scroll / partial updates
576
+ $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
577
+ if (EZOIC_BLOCKED) return;
578
+ scheduleRun();
579
+ });
350
580
  }
351
581
 
352
- function onAjaxifyEnd() {
353
- // Kick scan for new page
354
- prefetchConfig();
355
- Ad.scheduleScan();
582
+ function bindScroll() {
583
+ let ticking = false;
584
+ window.addEventListener('scroll', () => {
585
+ if (ticking) return;
586
+ ticking = true;
587
+ window.requestAnimationFrame(() => {
588
+ ticking = false;
589
+ if (!EZOIC_BLOCKED) scheduleRun();
590
+ });
591
+ }, { passive: true });
356
592
  }
357
593
 
358
- // NodeBB exposes ajaxify events on document
359
- on('ajaxify.start', onAjaxifyStart);
360
- on('ajaxify.end', onAjaxifyEnd);
594
+ // ---------- boot ----------
361
595
 
362
- // First load
363
- once('DOMContentLoaded', () => {
596
+ state.pageKey = getPageKey();
597
+ warmUpNetwork();
598
+ patchShowAds();
599
+ ensurePreloadObserver();
600
+ ensureDomObserver();
364
601
 
365
- prefetchConfig();
366
- Ad.scheduleScan();
367
- });
602
+ bindNodeBB();
603
+ bindScroll();
368
604
 
605
+ // First paint: try hero + run
606
+ EZOIC_BLOCKED = false;
607
+ insertHeroAdEarly().catch(() => {});
608
+ scheduleRun();
369
609
  })();
package/public/style.css CHANGED
@@ -1,9 +1,21 @@
1
- .ezoic-ad,
2
- .ezoic-ad *,
3
- span.ezoic-ad,
4
- span[class*="ezoic"] {
5
- min-height: 0 !important;
6
- min-width: 0 !important;
1
+ /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
2
+ .ezoic-ad {
3
+ display: block;
4
+ width: 100%;
5
+ margin: 0 !important;
6
+ padding: 0 !important;
7
+ overflow: hidden;
7
8
  }
8
9
 
9
- .ezoic-ad-wrapper{margin:0;padding:0;list-style:none;}
10
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
11
+ margin: 0 !important;
12
+ padding: 0 !important;
13
+ min-height: 1px; /* keeps placeholder measurable for IO */
14
+ }
15
+
16
+ /* Ezoic sometimes wraps in extra spans/divs with margins */
17
+ .ezoic-ad span.ezoic-ad,
18
+ .ezoic-ad .ezoic-ad {
19
+ margin: 0 !important;
20
+ padding: 0 !important;
21
+ }