nodebb-plugin-ezoic-infinite 1.5.8 → 1.5.9

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 : g));
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : String(g)));
72
72
  }
73
73
 
74
74
  plugin.onSettingsSet = function (data) {
@@ -105,13 +105,13 @@ plugin.init = async ({ router, middleware }) => {
105
105
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
106
106
  router.get('/api/admin/plugins/ezoic-infinite', render);
107
107
 
108
- router.get('/api/plugins/ezoic-infinite/config', middleware.authenticate, async (req, res) => {
108
+ router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
109
  const settings = await getSettings();
110
- const uid = (typeof req.uid === 'number' ? req.uid : (req.user && typeof req.user.uid === 'number' ? req.user.uid : 0));
111
- const excluded = await isUserExcluded(uid, settings.excludedGroups);
110
+ const excluded = await isUserExcluded((req.uid || (req.user && req.user.uid)), settings.excludedGroups);
112
111
 
113
112
  res.json({
114
113
  excluded,
114
+ excludedGroups: settings.excludedGroups,
115
115
  enableBetweenAds: settings.enableBetweenAds,
116
116
  showFirstTopicAd: settings.showFirstTopicAd,
117
117
  placeholderIds: settings.placeholderIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.8",
3
+ "version": "1.5.9",
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,609 +1,367 @@
1
- (function () {
2
- 'use strict';
3
-
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;
11
-
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
-
15
- // Insert at most N ads per run to keep the UI smooth on infinite scroll
16
- const MAX_INSERTS_PER_RUN = 3;
17
-
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();
1
+ 'use strict';
56
2
 
57
- // ---------- small utils ----------
3
+ /**
4
+ * Ezoic Infinite Ads for NodeBB 4.x
5
+ * - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
6
+ * - Hard gate on server config (respects excluded groups BEFORE any injection)
7
+ * - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
8
+ * - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
9
+ */
58
10
 
59
- function normalizeBool(v) {
60
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
11
+ (function () {
12
+ const CFG_URL = '/api/plugins/ezoic-infinite/config';
13
+
14
+ // -------------------------
15
+ // Utilities
16
+ // -------------------------
17
+ const now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
18
+ const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
19
+
20
+ function on(ev, fn) { document.addEventListener(ev, fn); }
21
+ function once(ev, fn) {
22
+ const h = (e) => { document.removeEventListener(ev, h); fn(e); };
23
+ document.addEventListener(ev, h);
61
24
  }
62
25
 
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);
26
+ // -------------------------
27
+ // Global state / navigation token
28
+ // -------------------------
29
+ let navToken = 0; // increment on ajaxify.start (page is about to be replaced)
30
+ let rafScheduled = false;
31
+
32
+ // resolved config (or null if failed)
33
+ let cfgPromise = null;
34
+ let cfg = null;
35
+
36
+ // -------------------------
37
+ // Placeholder Registry (keeps IDs alive)
38
+ // -------------------------
39
+ const Pool = {
40
+ el: null,
41
+ ensurePool() {
42
+ if (this.el && document.body.contains(this.el)) return this.el;
43
+ const d = document.createElement('div');
44
+ d.id = 'ezoic-placeholder-pool';
45
+ d.style.display = 'none';
46
+ d.setAttribute('aria-hidden', 'true');
47
+ document.body.appendChild(d);
48
+ this.el = d;
49
+ return d;
50
+ },
51
+ ensurePlaceholder(id) {
52
+ if (!id) return null;
53
+ let ph = document.getElementById(id);
54
+ if (ph) return ph;
55
+ const pool = this.ensurePool();
56
+ ph = document.createElement('div');
57
+ ph.id = id;
58
+ pool.appendChild(ph);
59
+ return ph;
60
+ },
61
+ returnToPool(id) {
62
+ const ph = document.getElementById(id);
63
+ if (!ph) return;
64
+ const pool = this.ensurePool();
65
+ if (ph.parentElement !== pool) {
66
+ pool.appendChild(ph);
71
67
  }
72
68
  }
73
- return out;
74
- }
75
-
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);
83
- }
84
-
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
- }
95
-
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
- }
69
+ };
108
70
 
109
- function getTopicItems() {
110
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
71
+ // -------------------------
72
+ // Ezoic showAds safe wrapper (installed even if defined later)
73
+ // -------------------------
74
+ function normalizeIds(argsLike) {
75
+ const args = Array.from(argsLike);
76
+ if (!args.length) return [];
77
+ // showAds([id1,id2]) or showAds([[...]]) edge
78
+ if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
79
+ return args.flat().map(String);
111
80
  }
112
81
 
113
- function getCategoryItems() {
114
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
115
- }
82
+ function wrapShowAds(original) {
83
+ if (!original || original.__nodebbSafeWrapped) return original;
116
84
 
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
- }
85
+ const wrapped = function (...args) {
86
+ const ids = normalizeIds(args);
87
+ if (!ids.length) return;
128
88
 
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
- }
89
+ for (const id of ids) {
90
+ // Ensure placeholder exists somewhere (pool), so ez-standalone won't log "does not exist"
91
+ Pool.ensurePlaceholder(id);
154
92
 
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) {}
93
+ const el = document.getElementById(id);
94
+ if (el && document.body.contains(el)) {
95
+ try {
96
+ // Call one-by-one to avoid batch logging on missing nodes
97
+ original.call(window.ezstandalone, id);
98
+ } catch (e) {
99
+ // swallow: Ezoic can throw if called during transitions
182
100
  }
183
- };
184
- } catch (e) {}
101
+ }
102
+ }
185
103
  };
186
104
 
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
- }
105
+ wrapped.__nodebbSafeWrapped = true;
106
+ return wrapped;
195
107
  }
196
108
 
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;
109
+ function installShowAdsHook() {
110
+ // Minimal + Ezoic-safe:
111
+ // - ensure queues exist (prevents `_ezaq` / `cmd` undefined)
112
+ // - wrap showAds when it exists
113
+ // - ALSO queue a wrap inside ezstandalone.cmd for when Ezoic initializes later
114
+ window._ezaq = window._ezaq || [];
115
+ window.ezstandalone = window.ezstandalone || {};
116
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
117
+
118
+ if (window.ezstandalone.showAds) {
119
+ window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
208
120
  }
209
- }
210
-
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);
216
- }
217
-
218
- // ---------- insertion primitives ----------
219
121
 
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;
122
+ // When standalone finishes loading, it will run cmd queue — wrap then too.
123
+ window.ezstandalone.cmd.push(function () {
124
+ if (window.ezstandalone && window.ezstandalone.showAds) {
125
+ window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
126
+ }
127
+ });
227
128
  }
228
129
 
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;
130
+ function ezCmd(fn) {
131
+ // Tokenize so queued callbacks don't run after navigation
132
+ const tokenAtSchedule = navToken;
133
+ window.ezstandalone = window.ezstandalone || {};
134
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
135
+ window.ezstandalone.cmd.push(function () {
136
+ if (tokenAtSchedule !== navToken) return;
137
+ try { fn(); } catch (e) {}
138
+ });
251
139
  }
252
140
 
253
- function findWrap(kindClass, afterPos) {
254
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
255
- }
141
+ // -------------------------
142
+ // Config (hard gate)
143
+ // -------------------------
144
+ function prefetchConfig() {
145
+ if (cfgPromise) return cfgPromise;
146
+
147
+ cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
148
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
149
+ .then(data => {
150
+ cfg = data;
151
+ // Pre-create placeholders in pool so Ezoic never complains even before first injection
152
+ const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
153
+ ids.forEach(id => Pool.ensurePlaceholder(id));
154
+ return cfg;
155
+ })
156
+ .catch(() => {
157
+ cfg = null;
158
+ return null;
159
+ });
256
160
 
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
- }
161
+ return cfgPromise;
273
162
  }
274
163
 
275
- function pickId(pool) {
276
- return pool.length ? pool.shift() : null;
164
+ function parsePlaceholderIds(s) {
165
+ return String(s || '')
166
+ .split(',')
167
+ .map(x => x.trim())
168
+ .filter(Boolean);
277
169
  }
278
170
 
279
- function showAd(id) {
280
- if (!id || EZOIC_BLOCKED) return;
281
-
282
- const now = Date.now();
283
- const last = state.lastShowById.get(id) || 0;
284
- if (now - last < 1500) return; // basic throttle
285
-
286
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
287
- if (!ph || !ph.isConnected) return;
288
-
289
- state.lastShowById.set(id, now);
171
+ // -------------------------
172
+ // Ad insertion logic
173
+ // -------------------------
174
+ const Ad = {
175
+ // throttle showAds per placeholder id
176
+ lastShowAt: new Map(),
177
+ minShowIntervalMs: 1500,
290
178
 
291
- try {
292
- window.ezstandalone = window.ezstandalone || {};
293
- const ez = window.ezstandalone;
294
-
295
- // Fast path
296
- if (typeof ez.showAds === 'function') {
297
- ez.showAds(id);
298
- sessionDefinedIds.add(id);
299
- return;
179
+ // Observers
180
+ io: null,
181
+ mo: null,
182
+
183
+ // Bookkeeping
184
+ insertedKeys: new Set(), // avoid duplicate injection on re-renders
185
+
186
+ initObservers() {
187
+ if (!this.io) {
188
+ this.io = new IntersectionObserver((entries) => {
189
+ for (const e of entries) {
190
+ if (!e.isIntersecting) continue;
191
+ const id = e.target && e.target.getAttribute('data-ezoic-id');
192
+ if (id) this.requestShow(id);
193
+ }
194
+ }, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
300
195
  }
301
196
 
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
- });
197
+ if (!this.mo) {
198
+ this.mo = new MutationObserver(() => this.scheduleScan());
199
+ const root = document.querySelector('#content, #panel, main, body');
200
+ if (root) this.mo.observe(root, { childList: true, subtree: true });
315
201
  }
316
- } catch (e) {}
317
- }
318
-
319
- // ---------- preload / above-the-fold ----------
202
+ },
203
+
204
+ disconnectObservers() {
205
+ try { this.io && this.io.disconnect(); } catch (e) {}
206
+ try { this.mo && this.mo.disconnect(); } catch (e) {}
207
+ this.io = null;
208
+ this.mo = null;
209
+ },
210
+
211
+ scheduleScan() {
212
+ if (rafScheduled) return;
213
+ rafScheduled = true;
214
+ requestAnimationFrame(() => {
215
+ rafScheduled = false;
216
+ this.scanAndInject();
217
+ });
218
+ },
320
219
 
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) {}
220
+ async scanAndInject() {
221
+ const c = await prefetchConfig();
222
+ if (!c) return;
329
223
 
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
- }
224
+ if (c.excluded) return; // HARD stop: never inject for excluded users
340
225
 
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 ----------
226
+ installShowAdsHook();
227
+ this.initObservers();
355
228
 
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
- }
229
+ if (c.enableBetweenAds) this.injectBetweenTopics(c);
230
+ // Extend: categories ads if you use them
231
+ },
365
232
 
366
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
367
- if (!items.length) return 0;
233
+ // NodeBB topic list injection (between li rows)
234
+ injectBetweenTopics(c) {
235
+ const container =
236
+ document.querySelector('.category .topic-list') ||
237
+ document.querySelector('.topics .topic-list') ||
238
+ document.querySelector('.topic-list');
368
239
 
369
- const targets = computeTargets(items.length, interval, showFirst);
370
- let inserted = 0;
240
+ if (!container) return;
371
241
 
372
- for (const afterPos of targets) {
373
- if (inserted >= MAX_INSERTS_PER_RUN) break;
242
+ const rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item'));
243
+ if (!rows.length) return;
374
244
 
375
- const el = items[afterPos - 1];
376
- if (!el || !el.isConnected) continue;
377
- if (isAdjacentAd(el)) continue;
378
- if (findWrap(kindClass, afterPos)) continue;
245
+ const ids = parsePlaceholderIds(c.placeholderIds);
246
+ if (!ids.length) return;
379
247
 
380
- const id = pickId(pool);
381
- if (!id) break;
248
+ const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
382
249
 
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;
250
+ // HERO: early, above-the-fold (first eligible insertion point)
251
+ if (c.showFirstTopicAd) {
252
+ this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
389
253
  }
390
254
 
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; }
255
+ // Between rows
256
+ let idIndex = 0;
257
+ for (let i = interval; i < rows.length; i += interval) {
258
+ idIndex = (idIndex + 1) % ids.length;
259
+ this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
260
+ }
261
+ },
438
262
 
439
- const id = pickId(pool);
440
- if (!id) return;
263
+ insertAdAfterRow(rowEl, placeholderId, key) {
264
+ if (!rowEl || !placeholderId || !key) return;
265
+ if (this.insertedKeys.has(key)) return;
441
266
 
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
- }
267
+ // If already present nearby, skip
268
+ if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
269
+ this.insertedKeys.add(key);
270
+ return;
271
+ }
449
272
 
450
- state.heroDoneForPage = true;
451
- observePlaceholder(id);
452
- }
273
+ const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
274
+ const wrapper = document.createElement(isLi ? 'li' : 'div');
275
+ wrapper.className = 'ezoic-ad-wrapper';
276
+ wrapper.setAttribute('role', 'presentation');
277
+ wrapper.setAttribute('data-ezoic-id', placeholderId);
453
278
 
454
- async function runCore() {
455
- if (EZOIC_BLOCKED) return;
279
+ // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
280
+ if (isLi && rowEl.classList.contains('list-group-item')) {
281
+ wrapper.classList.add('list-group-item');
282
+ }
456
283
 
457
- patchShowAds();
284
+ // Ensure placeholder exists (in pool) then move it into wrapper
285
+ const ph = Pool.ensurePlaceholder(placeholderId);
286
+ // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
287
+ try { wrapper.appendChild(ph); } catch (e) {}
458
288
 
459
- const cfg = await fetchConfigOnce();
460
- if (!cfg || cfg.excluded) return;
461
- initPools(cfg);
289
+ // Insert into DOM
290
+ rowEl.insertAdjacentElement('afterend', wrapper);
291
+ this.insertedKeys.add(key);
462
292
 
463
- const kind = getKind();
293
+ // Observe for preloading and request a show
294
+ if (this.io) this.io.observe(wrapper);
464
295
 
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
- );
296
+ // If above fold, request immediately (no timeout)
297
+ const rect = wrapper.getBoundingClientRect();
298
+ if (rect.top < (window.innerHeight * 1.5)) {
299
+ this.requestShow(placeholderId);
475
300
  }
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
- );
301
+ },
302
+
303
+ requestShow(placeholderId) {
304
+ if (!placeholderId) return;
305
+
306
+ const last = this.lastShowAt.get(placeholderId) || 0;
307
+ const t = now();
308
+ if ((t - last) < this.minShowIntervalMs) return;
309
+ this.lastShowAt.set(placeholderId, t);
310
+
311
+ // Ensure placeholder exists in DOM (pool or wrapper)
312
+ Pool.ensurePlaceholder(placeholderId);
313
+
314
+ // Use ez cmd queue if available
315
+ const doShow = () => {
316
+ if (!window.ezstandalone || !window.ezstandalone.showAds) return;
317
+ // showAds is wrapped; calling with one id is safest
318
+ window.ezstandalone.showAds(placeholderId);
319
+ };
320
+
321
+ if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
322
+ ezCmd(doShow);
323
+ } else {
324
+ doShow();
486
325
  }
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
- );
497
- }
498
- }
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) {}
326
+ },
327
+
328
+ // On navigation, return all placeholders to pool so they still exist
329
+ reclaimPlaceholders() {
330
+ const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
331
+ wrappers.forEach(w => {
332
+ const id = w.getAttribute('data-ezoic-id');
333
+ if (id) Pool.returnToPool(id);
521
334
  });
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
- }
539
-
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
- });
335
+ // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
336
+ this.insertedKeys.clear();
337
+ this.lastShowAt.clear();
338
+ }
339
+ };
574
340
 
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
- });
341
+ // -------------------------
342
+ // NodeBB hooks
343
+ // -------------------------
344
+ function onAjaxifyStart() {
345
+ navToken++;
346
+ Ad.disconnectObservers();
347
+ Ad.reclaimPlaceholders();
580
348
  }
581
349
 
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 });
350
+ function onAjaxifyEnd() {
351
+ // Kick scan for new page
352
+ prefetchConfig();
353
+ Ad.scheduleScan();
592
354
  }
593
355
 
594
- // ---------- boot ----------
595
-
596
- state.pageKey = getPageKey();
597
- warmUpNetwork();
598
- patchShowAds();
599
- ensurePreloadObserver();
600
- ensureDomObserver();
356
+ // NodeBB exposes ajaxify events on document
357
+ on('ajaxify.start', onAjaxifyStart);
358
+ on('ajaxify.end', onAjaxifyEnd);
601
359
 
602
- bindNodeBB();
603
- bindScroll();
360
+ // First load
361
+ once('DOMContentLoaded', () => {
362
+ installShowAdsHook();
363
+ prefetchConfig();
364
+ Ad.scheduleScan();
365
+ });
604
366
 
605
- // First paint: try hero + run
606
- EZOIC_BLOCKED = false;
607
- insertHeroAdEarly().catch(() => {});
608
- scheduleRun();
609
367
  })();
package/public/style.css CHANGED
@@ -1,21 +1,9 @@
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;
1
+ .ezoic-ad,
2
+ .ezoic-ad *,
3
+ span.ezoic-ad,
4
+ span[class*="ezoic"] {
5
+ min-height: 0 !important;
6
+ min-width: 0 !important;
8
7
  }
9
8
 
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
- }
9
+ .ezoic-ad-wrapper{margin:0;padding:0;list-style:none;}