@omega.js/client 0.1.0
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/LICENSE +98 -0
- package/README.md +874 -0
- package/dist/index.js +999 -0
- package/dist/modules/analytics.js +584 -0
- package/dist/modules/auth.js +469 -0
- package/dist/modules/bindings.js +319 -0
- package/dist/modules/device.js +282 -0
- package/dist/modules/dom.js +96 -0
- package/dist/modules/features.js +30 -0
- package/dist/modules/firestore.js +313 -0
- package/dist/modules/form-manager.js +1577 -0
- package/dist/modules/icon-core.js +226 -0
- package/dist/modules/icon-renderer.js +149 -0
- package/dist/modules/live-page.js +235 -0
- package/dist/modules/logger.js +36 -0
- package/dist/modules/motion.js +853 -0
- package/dist/modules/notifications.js +433 -0
- package/dist/modules/path-prefix.js +22 -0
- package/dist/modules/request.js +223 -0
- package/dist/modules/sentry.js +108 -0
- package/dist/modules/service-worker.js +237 -0
- package/dist/modules/storage.js +133 -0
- package/dist/modules/triggers.js +117 -0
- package/dist/modules/utilities.js +479 -0
- package/dist/modules/vert-document.js +354 -0
- package/dist/modules/verts.js +1133 -0
- package/dist/vendor/account/engine.js +182 -0
- package/dist/vendor/account/features.js +220 -0
- package/dist/vendor/account/index.js +53 -0
- package/dist/vendor/account/schema.js +272 -0
- package/dist/vendor/account/subscription.js +38 -0
- package/dist/vendor/analytics/adapters/ga4.js +26 -0
- package/dist/vendor/analytics/adapters/meta.js +26 -0
- package/dist/vendor/analytics/adapters/resolve.js +130 -0
- package/dist/vendor/analytics/adapters/tiktok.js +27 -0
- package/dist/vendor/analytics/catalog.js +908 -0
- package/dist/vendor/analytics/consent.js +49 -0
- package/dist/vendor/analytics/core.js +141 -0
- package/dist/vendor/analytics/identity.js +136 -0
- package/dist/vendor/analytics/index.js +170 -0
- package/dist/vendor/analytics/logger.js +40 -0
- package/dist/vendor/analytics/transports/browser.js +110 -0
- package/dist/vendor/monitoring/browser.js +207 -0
- package/dist/vendor/monitoring/core.js +180 -0
- package/dist/vendor/monitoring/logger.js +39 -0
- package/docs/architecture.md +59 -0
- package/docs/bindings.md +235 -0
- package/docs/build-system.md +32 -0
- package/docs/cdp-debugging.md +29 -0
- package/docs/code-patterns.md +96 -0
- package/docs/common-tasks.md +36 -0
- package/docs/dependencies.md +19 -0
- package/docs/index.md +159 -0
- package/docs/modules.md +180 -0
- package/docs/shared/agent-docs.md +89 -0
- package/docs/shared/analytics.md +612 -0
- package/docs/shared/brands.md +51 -0
- package/docs/shared/breaking-changes.md +497 -0
- package/docs/shared/config.md +1387 -0
- package/docs/shared/deploys.md +215 -0
- package/docs/shared/icons.md +201 -0
- package/docs/shared/local-dev.md +147 -0
- package/docs/shared/logging.md +202 -0
- package/docs/shared/monitoring.md +153 -0
- package/docs/shared/publishing.md +183 -0
- package/docs/shared/rulings.md +34 -0
- package/docs/shared/testing.md +147 -0
- package/docs/shared/theming.md +604 -0
- package/docs/shared/translation.md +291 -0
- package/docs/shared/updates.md +61 -0
- package/docs/testing.md +9 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verts module — the fallback-ladder ad engine shared by every surface
|
|
3
|
+
* (docs/web/ads-system.md, phase 2).
|
|
4
|
+
*
|
|
5
|
+
* Three lanes, one implementation:
|
|
6
|
+
* 1. Provider lane (web only): AdSense. Script-load failure IS the adblock
|
|
7
|
+
* detector (no bait divs, no library) — blocked → straight to the
|
|
8
|
+
* fallback lane. Otherwise the <ins> is built per type and fill is
|
|
9
|
+
* awaited via a MutationObserver on data-ad-status (+ timeout).
|
|
10
|
+
* 2. Fallback lane (all surfaces): a sandboxed iframe to the resolved
|
|
11
|
+
* in-house source's /omega/verts/serve, origin-validated postMessage with
|
|
12
|
+
* a fixed vocabulary (omega-vert:set-dimensions / omega-vert:click), and
|
|
13
|
+
* HOST-owned lifecycle — rotation timer, staleness recovery
|
|
14
|
+
* (visibilitychange/online → reload when stale), and no-fill teardown
|
|
15
|
+
* (a unit that never reports dimensions within the fill timeout drops
|
|
16
|
+
* its frame and emits omega-vert:no-fill). The iframe only renders and
|
|
17
|
+
* reports; it never refreshes itself (kills the legacy chrome-error
|
|
18
|
+
* stranding).
|
|
19
|
+
* 3. Terminal lane (all surfaces): the built-in OMEGA promo. A unit NEVER
|
|
20
|
+
* renders empty — when every configured lane has failed (no provider /
|
|
21
|
+
* no fill / blocked, and no reachable in-house source) the host gets a
|
|
22
|
+
* REAL unit carrying the built-in promo: the same sandboxed iframe, the
|
|
23
|
+
* same postMessage vocabulary, the same host-owned sizing and click
|
|
24
|
+
* handling as the fallback lane, except the document arrives by srcdoc
|
|
25
|
+
* instead of over the wire (zero network, ever). omega-vert:no-fill
|
|
26
|
+
* still fires first — nothing was sold — followed by omega-vert:promo.
|
|
27
|
+
*
|
|
28
|
+
* Click tracking: analytics cannot run inside a cross-origin frame, so the
|
|
29
|
+
* legacy stack bounced every click through a top-level forward page that
|
|
30
|
+
* fired gtag before navigating. Here the frame posts omega-vert:click OUT and
|
|
31
|
+
* the HOST fires the vert_click event through this brand's own analytics —
|
|
32
|
+
* no forward page, no navigation delay. The destination carries the vert UTM
|
|
33
|
+
* set either way: the promo's link is tagged in-place here, a served vert's
|
|
34
|
+
* by the backend redirect route (both through applyVertUtm).
|
|
35
|
+
*
|
|
36
|
+
* Element binding (the mirrored-implementation surface): mount($el) arms one
|
|
37
|
+
* host lazily near the viewport (IntersectionObserver) reading its
|
|
38
|
+
* data-omega-vert* attributes; bind(root) scans [data-omega-vert] and mounts
|
|
39
|
+
* every match. The web verts/unit section delegates here, and desktop/
|
|
40
|
+
* extension bind the same vocabulary in phase 4 — one implementation.
|
|
41
|
+
*
|
|
42
|
+
* Theme: a unit follows the PAGE, not the OS. Mount reads `data-bs-theme` off
|
|
43
|
+
* the root element into the frame's theme (both lanes: the house serve URL's
|
|
44
|
+
* theme param and the promo document's stamp), a host's own
|
|
45
|
+
* data-omega-vert-theme pins it, and nothing set anywhere leaves the frame on
|
|
46
|
+
* its own prefers-color-scheme branch. A flip after render re-stamps the promo
|
|
47
|
+
* frames (a srcdoc assignment, no network); a house frame keeps the theme it
|
|
48
|
+
* mounted with, since re-theming it would mean re-fetching it.
|
|
49
|
+
*
|
|
50
|
+
* Source resolution (advertising.providers.inhouse.source):
|
|
51
|
+
* 'self' → this brand's api URL (manager.getApiUrl())
|
|
52
|
+
* 'company' → the parent company's api URL (config.company.url through the
|
|
53
|
+
* same api-URL derivation — the config company layer supplies
|
|
54
|
+
* company.url to every sub-brand)
|
|
55
|
+
* full URL → used verbatim (trailing slashes stripped)
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { createLogger } from './logger.js';
|
|
59
|
+
import {
|
|
60
|
+
renderVertDocument,
|
|
61
|
+
applyVertUtm,
|
|
62
|
+
MESSAGE_DIMENSIONS,
|
|
63
|
+
MESSAGE_CLICK,
|
|
64
|
+
OMEGA_ACCENT,
|
|
65
|
+
UTM_MEDIUM,
|
|
66
|
+
UTM_CAMPAIGN_PROMO,
|
|
67
|
+
} from './vert-document.js';
|
|
68
|
+
|
|
69
|
+
const logger = createLogger('verts');
|
|
70
|
+
|
|
71
|
+
// Size presets (name → max-height in pixels) — the ONE px table (SSOT; the
|
|
72
|
+
// section scss carries no copy, the module applies the constraint inline).
|
|
73
|
+
const SIZE_PRESETS = {
|
|
74
|
+
banner: 150,
|
|
75
|
+
leaderboard: 90,
|
|
76
|
+
rectangle: 250,
|
|
77
|
+
'large-rectangle': 600,
|
|
78
|
+
skyscraper: 600,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// AdSense unit attributes per type — the ONE layout table (the legacy
|
|
82
|
+
// duplicated the in-feed layout keys across includes).
|
|
83
|
+
const ADSENSE_FORMATS = {
|
|
84
|
+
display: { style: 'display:block', attributes: { 'data-ad-format': 'auto', 'data-full-width-responsive': 'true' }, slotKey: 'displaySlot' },
|
|
85
|
+
'in-article': { style: 'display:block; text-align:center', attributes: { 'data-ad-layout': 'in-article', 'data-ad-format': 'fluid' }, slotKey: 'inArticleSlot' },
|
|
86
|
+
'in-feed': { style: 'display:block', attributes: { 'data-ad-format': 'fluid' }, slotKey: 'inFeedSlot', layoutKeys: { 'image-above': '-6t+ed+2x-11-88', 'image-side': '-fb+5w+4e-db+86' } },
|
|
87
|
+
multiplex: { style: 'display:block', attributes: { 'data-ad-format': 'autorelaxed' }, slotKey: 'multiplexSlot' },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Sandbox attributes for the house iframe (legacy-proven set)
|
|
91
|
+
const IFRAME_SANDBOX = 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation';
|
|
92
|
+
|
|
93
|
+
// The promo frame is a srcdoc document, so allow-same-origin would hand it
|
|
94
|
+
// THIS page's origin (and with allow-scripts that is no sandbox at all). It
|
|
95
|
+
// drops out; the rest of the house set carries over verbatim.
|
|
96
|
+
const PROMO_SANDBOX = IFRAME_SANDBOX.split(' ').filter((token) => token !== 'allow-same-origin').join(' ');
|
|
97
|
+
|
|
98
|
+
// Lifecycle defaults — rotation OFF by default; staleness generous
|
|
99
|
+
const DEFAULT_FILL_TIMEOUT = 9000;
|
|
100
|
+
const DEFAULT_STALE_AFTER = 1000 * 60 * 10;
|
|
101
|
+
const DEFAULT_ROTATE_INTERVAL = 0;
|
|
102
|
+
const MAX_HEIGHT_CEILING = 1200;
|
|
103
|
+
|
|
104
|
+
// The terminal lane: the built-in OMEGA promo. A real unit in every way the
|
|
105
|
+
// fallback lane is one, with the document inlined by srcdoc: no network, no
|
|
106
|
+
// external image, literal colours per theme (both modes hold, themed page or
|
|
107
|
+
// not, since css variables do not cross the frame boundary).
|
|
108
|
+
const PROMO_URL = 'https://omegajs.dev';
|
|
109
|
+
const PROMO_ID = 'omega-promo';
|
|
110
|
+
const PROMO_MIN_HEIGHT = 90;
|
|
111
|
+
const PROMO_TITLE = 'Built with OMEGA';
|
|
112
|
+
const PROMO_DESCRIPTION = 'The full-stack JavaScript framework for web, backend, desktop, and extensions.';
|
|
113
|
+
const PROMO_BUTTON = 'Visit omegajs.dev';
|
|
114
|
+
|
|
115
|
+
// The promo's thumbnail: an inline svg mark, the ONE trusted-markup value the
|
|
116
|
+
// renderer ever receives (a local constant, never data)
|
|
117
|
+
const PROMO_MARK = '<svg class="omega-vert-image" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
|
|
118
|
+
+ '<rect x="0" y="0" width="24" height="24" fill="var(--omega-vert-accent)"></rect>'
|
|
119
|
+
+ '<path d="M13.2 4.5 7.6 13.1h3.4l-.9 6.4 5.8-8.8h-3.4z" fill="var(--omega-vert-accent-text)"></path>'
|
|
120
|
+
+ '</svg>';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a size preset name or raw pixel value to a max-height in px.
|
|
124
|
+
* @param {string|number} value - preset name ('banner') or raw px ('300')
|
|
125
|
+
* @returns {number|null} pixels, or null when unresolvable
|
|
126
|
+
*/
|
|
127
|
+
function resolveSizePx(value) {
|
|
128
|
+
if (!value) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (SIZE_PRESETS[value]) {
|
|
133
|
+
return SIZE_PRESETS[value];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const num = parseInt(value, 10);
|
|
137
|
+
return isNaN(num) || num <= 0 ? null : num;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clamp a reported iframe height to sane bounds.
|
|
142
|
+
* @param {*} height - reported height (any postMessage payload value)
|
|
143
|
+
* @param {number} [maxPx] - unit max-height (size preset), ceiling otherwise
|
|
144
|
+
* @returns {number|null} clamped integer px, or null when not a usable number
|
|
145
|
+
*/
|
|
146
|
+
function clampHeight(height, maxPx) {
|
|
147
|
+
const num = parseInt(height, 10);
|
|
148
|
+
if (isNaN(num) || num <= 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return Math.min(num, maxPx || MAX_HEIGHT_CEILING);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The host page's bare hostname — every vert click's utm_source (bare, no
|
|
157
|
+
* www., matching the backend's normalizeHost).
|
|
158
|
+
* @returns {string} the hostname, or '' when there is no page
|
|
159
|
+
*/
|
|
160
|
+
function hostHostname() {
|
|
161
|
+
const hostname = (typeof window !== 'undefined' && window.location?.hostname) || '';
|
|
162
|
+
|
|
163
|
+
return hostname.replace(/^www\./, '');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The utm_source every vert click carries: the HOST brand's own id from its
|
|
168
|
+
* omega config (brand.id), with the parent host as the fallback when no id is
|
|
169
|
+
* available. The house lane carries the same value to the backend as the serve
|
|
170
|
+
* URL's `brand` param, so both lanes tag with one identity.
|
|
171
|
+
* @param {object} manager - the client singleton
|
|
172
|
+
* @returns {string} the brand id, or the host page's hostname
|
|
173
|
+
*/
|
|
174
|
+
function utmSource(manager) {
|
|
175
|
+
return manager?.config?.brand?.id || hostHostname();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The promo's click destination: omegajs.dev tagged with the vert UTM set
|
|
180
|
+
* through the ONE helper both lanes use.
|
|
181
|
+
* @param {string} [size] - the slot's size preset, carried as utm_content
|
|
182
|
+
* @param {string} [source] - utm_source (the brand id); the host page's
|
|
183
|
+
* hostname when absent
|
|
184
|
+
* @returns {string} the tagged promo URL
|
|
185
|
+
*/
|
|
186
|
+
function promoHref(size, source) {
|
|
187
|
+
return applyVertUtm(PROMO_URL, {
|
|
188
|
+
source: source || hostHostname(),
|
|
189
|
+
medium: UTM_MEDIUM,
|
|
190
|
+
campaign: UTM_CAMPAIGN_PROMO,
|
|
191
|
+
content: size,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fire the host-side click event. Analytics cannot run inside a cross-origin
|
|
197
|
+
* vert frame, so the legacy stack bounced every click through a top-level
|
|
198
|
+
* forward page that fired gtag before navigating. The frame now posts
|
|
199
|
+
* omega-vert:click OUT to the host instead, and the HOST's own analytics
|
|
200
|
+
* fires here — no forward page, no navigation delay.
|
|
201
|
+
* @param {object} manager - the client singleton
|
|
202
|
+
* @param {object} detail - { id } from the click message
|
|
203
|
+
* @param {object} options - the unit's mount options ({ size, ... })
|
|
204
|
+
* @param {string} lane - 'house' | 'promo'
|
|
205
|
+
*/
|
|
206
|
+
function trackClick(manager, detail, options, lane) {
|
|
207
|
+
try {
|
|
208
|
+
manager.analytics().event('vert_click', {
|
|
209
|
+
vert_id: detail.id || '',
|
|
210
|
+
vert_lane: lane,
|
|
211
|
+
vert_campaign: lane === 'promo' ? UTM_CAMPAIGN_PROMO : (detail.id || ''),
|
|
212
|
+
vert_slot: options?.size || '',
|
|
213
|
+
vert_source: hostHostname(),
|
|
214
|
+
});
|
|
215
|
+
} catch (e) {
|
|
216
|
+
logger.error('vert_click analytics error:', e);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build the promo document the terminal frame carries: the SAME renderer the
|
|
222
|
+
* backend's serve route calls, fed the house promo data. The promo-only parts
|
|
223
|
+
* are the two things a served unit cannot share: the trusted inline-svg mark
|
|
224
|
+
* (a local constant, so the lane still makes zero network requests) and the
|
|
225
|
+
* omega indigo accent pair.
|
|
226
|
+
* @param {number|null} maxPx - the unit's resolved size in px (null = unsized)
|
|
227
|
+
* @param {string} [theme] - 'light' | 'dark' passthrough; unset follows the OS
|
|
228
|
+
* @param {string} [targetOrigin] - the host origin messages are posted to
|
|
229
|
+
* @param {string} [size] - the slot's size preset, carried as utm_content
|
|
230
|
+
* @param {number} [width] - the host's measured width in px (0 = unknown)
|
|
231
|
+
* @param {string} [source] - utm_source (the brand id); the host page's
|
|
232
|
+
* hostname when absent
|
|
233
|
+
* @returns {string} a complete html document
|
|
234
|
+
*/
|
|
235
|
+
function buildPromoDocument(maxPx, theme, targetOrigin, size, width, source) {
|
|
236
|
+
return renderVertDocument({
|
|
237
|
+
id: PROMO_ID,
|
|
238
|
+
href: promoHref(size, source),
|
|
239
|
+
title: PROMO_TITLE,
|
|
240
|
+
description: PROMO_DESCRIPTION,
|
|
241
|
+
button: PROMO_BUTTON,
|
|
242
|
+
imageMarkup: PROMO_MARK,
|
|
243
|
+
theme,
|
|
244
|
+
width: width || 0,
|
|
245
|
+
height: maxPx || 0,
|
|
246
|
+
accent: OMEGA_ACCENT,
|
|
247
|
+
targetOrigin,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The origin the promo document posts its messages to.
|
|
253
|
+
* @returns {string} this page's origin, or '*' when it is unknown
|
|
254
|
+
*/
|
|
255
|
+
function promoTargetOrigin() {
|
|
256
|
+
return (typeof window !== 'undefined' && window.location?.origin) || '*';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Build the terminal promo frame: the house iframe's attribute set, with the
|
|
261
|
+
* document carried by srcdoc instead of a serve URL (no `src`, no request).
|
|
262
|
+
* @param {number|null} maxPx - the unit's resolved size in px (null = unsized)
|
|
263
|
+
* @param {string} [theme] - 'light' | 'dark' passthrough
|
|
264
|
+
* @param {string} [size] - the slot's size preset, carried as utm_content
|
|
265
|
+
* @param {number} [width] - the host's measured width in px (0 = unknown)
|
|
266
|
+
* @param {string} [source] - utm_source (the brand id); the host page's
|
|
267
|
+
* hostname when absent
|
|
268
|
+
* @returns {Element} the promo iframe
|
|
269
|
+
*/
|
|
270
|
+
function buildPromo(maxPx, theme, size, width, source) {
|
|
271
|
+
const $iframe = document.createElement('iframe');
|
|
272
|
+
const targetOrigin = promoTargetOrigin();
|
|
273
|
+
|
|
274
|
+
$iframe.className = 'omega-vert-promo';
|
|
275
|
+
$iframe.setAttribute('sandbox', PROMO_SANDBOX);
|
|
276
|
+
$iframe.setAttribute('frameborder', '0');
|
|
277
|
+
$iframe.setAttribute('scrolling', 'no');
|
|
278
|
+
$iframe.setAttribute('allowtransparency', 'true');
|
|
279
|
+
$iframe.setAttribute('title', 'Sponsored');
|
|
280
|
+
$iframe.style.setProperty('display', 'block');
|
|
281
|
+
$iframe.style.setProperty('width', '100%');
|
|
282
|
+
$iframe.style.setProperty('border', '0');
|
|
283
|
+
$iframe.style.height = `${maxPx || PROMO_MIN_HEIGHT}px`;
|
|
284
|
+
$iframe.srcdoc = buildPromoDocument(maxPx, theme, targetOrigin, size, width, source);
|
|
285
|
+
|
|
286
|
+
return $iframe;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The terminal lane's mounted unit: the promo frame plus the same
|
|
291
|
+
* host-owned message handling a house unit has (sizing from the reported
|
|
292
|
+
* dimensions, clicks forwarded), minus everything that needs a server:
|
|
293
|
+
* no fill timer, no rotation, no staleness recovery.
|
|
294
|
+
*/
|
|
295
|
+
class PromoUnit {
|
|
296
|
+
/**
|
|
297
|
+
* @param {object} manager - the client singleton (analytics on click)
|
|
298
|
+
* @param {Element} $el - host element the frame mounts into
|
|
299
|
+
* @param {object} options - { size, theme } shape the frame
|
|
300
|
+
* @param {Function} emit - (name, detail) host emitter
|
|
301
|
+
*/
|
|
302
|
+
constructor(manager, $el, options, emit) {
|
|
303
|
+
this.manager = manager;
|
|
304
|
+
this.$el = $el;
|
|
305
|
+
this.options = options;
|
|
306
|
+
this.emit = emit;
|
|
307
|
+
this.maxHeight = resolveSizePx(options.size);
|
|
308
|
+
// The click tag's identity: this brand's own id (host fallback)
|
|
309
|
+
this.utmSource = utmSource(manager);
|
|
310
|
+
|
|
311
|
+
this.destroyed = false;
|
|
312
|
+
this.$iframe = null;
|
|
313
|
+
|
|
314
|
+
this._onMessage = (event) => this.handleMessage(event);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Mount the frame and open the message channel.
|
|
319
|
+
* @returns {PromoUnit} this
|
|
320
|
+
*/
|
|
321
|
+
load() {
|
|
322
|
+
if (this.maxHeight) {
|
|
323
|
+
this.$el.style.setProperty('max-height', `${this.maxHeight}px`, 'important');
|
|
324
|
+
this.$el.style.setProperty('overflow', 'hidden');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The host's measured width reaches the document so the narrow-compact
|
|
328
|
+
// and skyscraper-stacking branches are decidable (0 = unknown, fluid row)
|
|
329
|
+
this.$iframe = buildPromo(this.maxHeight, this.options.theme, this.options.size, this.$el.clientWidth || 0, this.utmSource);
|
|
330
|
+
this.$el.appendChild(this.$iframe);
|
|
331
|
+
|
|
332
|
+
window.addEventListener('message', this._onMessage);
|
|
333
|
+
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A sandboxed srcdoc frame has an opaque origin, so its messages arrive as
|
|
339
|
+
* origin "null", which is worthless as a check. Identity against THIS unit's own
|
|
340
|
+
* contentWindow is the validation instead (the house lane keeps its origin
|
|
341
|
+
* check unchanged).
|
|
342
|
+
* @param {MessageEvent} event
|
|
343
|
+
*/
|
|
344
|
+
handleMessage(event) {
|
|
345
|
+
if (this.destroyed || !this.$iframe?.contentWindow) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!event.source || event.source !== this.$iframe.contentWindow) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const message = event.data || {};
|
|
354
|
+
|
|
355
|
+
if (message.type === MESSAGE_DIMENSIONS) {
|
|
356
|
+
const height = clampHeight(message.height, this.maxHeight);
|
|
357
|
+
if (!height) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.$iframe.style.height = `${height}px`;
|
|
362
|
+
} else if (message.type === MESSAGE_CLICK) {
|
|
363
|
+
// The frame's own link opens omegajs.dev (target=_blank + rel) exactly
|
|
364
|
+
// like a served unit, so the message carries the host-side work: the
|
|
365
|
+
// analytics event no in-frame tracker could ever fire
|
|
366
|
+
trackClick(this.manager, { id: message.id }, this.options, 'promo');
|
|
367
|
+
this.emit('click', { id: message.id });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Re-stamp the frame for a page theme flip. The promo document is inline, so
|
|
373
|
+
* this is a srcdoc assignment with zero network (the house lane has no
|
|
374
|
+
* equivalent — its document comes over the wire, so it keeps the theme it
|
|
375
|
+
* mounted with).
|
|
376
|
+
* @param {string} theme - 'light' | 'dark'; '' follows the OS again
|
|
377
|
+
*/
|
|
378
|
+
setTheme(theme) {
|
|
379
|
+
if (this.destroyed || !this.$iframe || theme === this.options.theme) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.options = { ...this.options, theme };
|
|
384
|
+
this.$iframe.srcdoc = buildPromoDocument(this.maxHeight, theme, promoTargetOrigin(), this.options.size, this.$el.clientWidth || 0, this.utmSource);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Remove the message listener; the unit is inert afterwards. */
|
|
388
|
+
destroy() {
|
|
389
|
+
this.destroyed = true;
|
|
390
|
+
window.removeEventListener('message', this._onMessage);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* One mounted house/company ad unit — owns the iframe and its lifecycle.
|
|
396
|
+
*/
|
|
397
|
+
class VertUnit {
|
|
398
|
+
/**
|
|
399
|
+
* @param {object} manager - the client singleton
|
|
400
|
+
* @param {Element} $el - host element the iframe mounts into
|
|
401
|
+
* @param {object} options
|
|
402
|
+
* @param {string} options.source - resolved base URL of the ad server
|
|
403
|
+
* @param {string[]} [options.tags] - contextual tags for targeting
|
|
404
|
+
* @param {string} [options.size] - size preset or raw px (max-height)
|
|
405
|
+
* @param {string} [options.theme] - 'light' | 'dark' passthrough
|
|
406
|
+
* @param {string} [options.vertId] - pin a specific ad
|
|
407
|
+
* @param {number} [options.fillTimeout] - ms before the no-fill teardown
|
|
408
|
+
* @param {number} [options.staleAfter] - ms before a recovery reload
|
|
409
|
+
* @param {number} [options.rotateInterval] - ms between rotations (0 = off)
|
|
410
|
+
* @param {Function} [options.onFill] - first successful dimension report
|
|
411
|
+
* @param {Function} [options.onNoFill] - no fill (204 / never reported)
|
|
412
|
+
* @param {Function} [options.onExhausted] - teardown done; the host is free
|
|
413
|
+
* for the terminal lane
|
|
414
|
+
* @param {Function} [options.onClick] - click message from the frame
|
|
415
|
+
*/
|
|
416
|
+
constructor(manager, $el, options = {}) {
|
|
417
|
+
this.manager = manager;
|
|
418
|
+
this.$el = $el;
|
|
419
|
+
this.options = options;
|
|
420
|
+
|
|
421
|
+
this.source = String(options.source || '').replace(/\/+$/, '');
|
|
422
|
+
this.sourceOrigin = new URL(this.source).origin;
|
|
423
|
+
this.maxHeight = resolveSizePx(options.size);
|
|
424
|
+
|
|
425
|
+
this.filled = false;
|
|
426
|
+
this.destroyed = false;
|
|
427
|
+
this.lastLoadedAt = 0;
|
|
428
|
+
this.staleAfter = options.staleAfter || DEFAULT_STALE_AFTER;
|
|
429
|
+
this.rotateInterval = options.rotateInterval || DEFAULT_ROTATE_INTERVAL;
|
|
430
|
+
this.fillTimeout = options.fillTimeout || DEFAULT_FILL_TIMEOUT;
|
|
431
|
+
|
|
432
|
+
this.$iframe = null;
|
|
433
|
+
this._fillTimer = null;
|
|
434
|
+
this._rotateTimer = null;
|
|
435
|
+
|
|
436
|
+
// Bound listeners — kept for removal on destroy
|
|
437
|
+
this._onMessage = (event) => this.handleMessage(event);
|
|
438
|
+
this._onRecover = () => this.recover();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The serve URL for one impression (cache-busted so rotation reloads
|
|
443
|
+
* always reselect).
|
|
444
|
+
* @returns {string}
|
|
445
|
+
*/
|
|
446
|
+
buildServeUrl() {
|
|
447
|
+
const url = new URL(`${this.source}/omega/verts/serve`);
|
|
448
|
+
|
|
449
|
+
if (typeof window !== 'undefined' && window.location?.host) {
|
|
450
|
+
url.searchParams.set('parent', window.location.host);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The click tag's identity travels with the impression: the serve route
|
|
454
|
+
// stamps it on the redirect URL, and the redirect route tags the stored
|
|
455
|
+
// link with it (the parent host stays the targeting input, and the
|
|
456
|
+
// fallback when this brand carries no id)
|
|
457
|
+
const brandId = this.manager?.config?.brand?.id;
|
|
458
|
+
if (brandId) {
|
|
459
|
+
url.searchParams.set('brand', brandId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const tags = this.options.tags || [];
|
|
463
|
+
if (tags.length) {
|
|
464
|
+
url.searchParams.set('tags', tags.join(','));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (this.options.vertId) {
|
|
468
|
+
url.searchParams.set('vertId', this.options.vertId);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (this.maxHeight) {
|
|
472
|
+
url.searchParams.set('height', String(this.maxHeight));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The host's measured width lets the served document decide its
|
|
476
|
+
// narrow-compact and skyscraper-stacking branches
|
|
477
|
+
if (this.$el.clientWidth) {
|
|
478
|
+
url.searchParams.set('width', String(this.$el.clientWidth));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (this.options.theme) {
|
|
482
|
+
url.searchParams.set('theme', this.options.theme);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
url.searchParams.set('t', String(Date.now()));
|
|
486
|
+
|
|
487
|
+
return url.toString();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Mount the iframe and start the lifecycle (fill timer, rotation,
|
|
492
|
+
* staleness listeners).
|
|
493
|
+
* @returns {VertUnit} this
|
|
494
|
+
*/
|
|
495
|
+
load() {
|
|
496
|
+
// Host constraint from the size preset — inline so the px table stays
|
|
497
|
+
// one place (AdSense-style important overrides can't relax it either)
|
|
498
|
+
if (this.maxHeight) {
|
|
499
|
+
this.$el.style.setProperty('max-height', `${this.maxHeight}px`, 'important');
|
|
500
|
+
this.$el.style.setProperty('overflow', 'hidden');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const $iframe = document.createElement('iframe');
|
|
504
|
+
$iframe.setAttribute('sandbox', IFRAME_SANDBOX);
|
|
505
|
+
$iframe.setAttribute('frameborder', '0');
|
|
506
|
+
$iframe.setAttribute('scrolling', 'no');
|
|
507
|
+
$iframe.setAttribute('allowtransparency', 'true');
|
|
508
|
+
$iframe.setAttribute('title', 'Sponsored');
|
|
509
|
+
$iframe.style.setProperty('display', 'block');
|
|
510
|
+
$iframe.style.setProperty('width', '100%');
|
|
511
|
+
$iframe.style.setProperty('border', '0');
|
|
512
|
+
$iframe.addEventListener('load', () => {
|
|
513
|
+
this.lastLoadedAt = Date.now();
|
|
514
|
+
});
|
|
515
|
+
$iframe.src = this.buildServeUrl();
|
|
516
|
+
|
|
517
|
+
this.$iframe = $iframe;
|
|
518
|
+
this.$el.appendChild($iframe);
|
|
519
|
+
|
|
520
|
+
// Fixed-vocabulary message channel + host-owned recovery hooks
|
|
521
|
+
window.addEventListener('message', this._onMessage);
|
|
522
|
+
window.addEventListener('online', this._onRecover);
|
|
523
|
+
document.addEventListener('visibilitychange', this._onRecover);
|
|
524
|
+
|
|
525
|
+
this._armFillTimer();
|
|
526
|
+
|
|
527
|
+
if (this.rotateInterval > 0) {
|
|
528
|
+
this._rotateTimer = setInterval(() => {
|
|
529
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
this.reload('rotate');
|
|
533
|
+
}, this.rotateInterval);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return this;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Origin-validated message handler — the source origin ONLY, and (when the
|
|
541
|
+
* browser supplies it) only this unit's own frame.
|
|
542
|
+
* @param {MessageEvent} event
|
|
543
|
+
*/
|
|
544
|
+
handleMessage(event) {
|
|
545
|
+
if (this.destroyed || event.origin !== this.sourceOrigin) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Multiple units on one page: only this unit's frame speaks to it
|
|
550
|
+
if (event.source && this.$iframe?.contentWindow && event.source !== this.$iframe.contentWindow) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const message = event.data || {};
|
|
555
|
+
|
|
556
|
+
if (message.type === MESSAGE_DIMENSIONS) {
|
|
557
|
+
const height = clampHeight(message.height, this.maxHeight);
|
|
558
|
+
if (!height) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.$iframe.style.height = `${height}px`;
|
|
563
|
+
this.lastLoadedAt = Date.now();
|
|
564
|
+
|
|
565
|
+
if (!this.filled) {
|
|
566
|
+
this.filled = true;
|
|
567
|
+
this._clearFillTimer();
|
|
568
|
+
this._emit('fill', { height });
|
|
569
|
+
}
|
|
570
|
+
} else if (message.type === MESSAGE_CLICK) {
|
|
571
|
+
// The frame's <a> navigates through the redirect route itself (which
|
|
572
|
+
// UTM-tags the destination) — the message carries the host-side work:
|
|
573
|
+
// the analytics event no in-frame tracker could ever fire
|
|
574
|
+
trackClick(this.manager, { id: message.id }, this.options, 'house');
|
|
575
|
+
this._emit('click', { id: message.id });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Staleness check — has the frame gone longer than staleAfter without a
|
|
581
|
+
* (re)load or dimension report?
|
|
582
|
+
* @param {number} [now]
|
|
583
|
+
* @returns {boolean}
|
|
584
|
+
*/
|
|
585
|
+
isStale(now) {
|
|
586
|
+
return ((now || Date.now()) - this.lastLoadedAt) > this.staleAfter;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Recovery hook (visibilitychange / online): reload a stale frame — the
|
|
591
|
+
* legacy self-refresh stranded chrome-error:// pages exactly here.
|
|
592
|
+
*/
|
|
593
|
+
recover() {
|
|
594
|
+
if (this.destroyed || !this.filled) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (this.isStale()) {
|
|
603
|
+
this.reload('stale');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Reload the iframe with a fresh serve URL (rotation / staleness).
|
|
609
|
+
* @param {string} [reason]
|
|
610
|
+
*/
|
|
611
|
+
reload(reason) {
|
|
612
|
+
if (this.destroyed || !this.$iframe) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
this.filled = false;
|
|
617
|
+
this.$iframe.src = this.buildServeUrl();
|
|
618
|
+
this._armFillTimer();
|
|
619
|
+
this._emit('reload', { reason });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* No fill: drop the frame, emit, tear down, and hand the host back to the
|
|
624
|
+
* ladder's terminal lane (onExhausted) — the unit never ends empty.
|
|
625
|
+
*/
|
|
626
|
+
noFill() {
|
|
627
|
+
if (this.destroyed) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (this.$iframe && typeof this.$iframe.remove === 'function') {
|
|
632
|
+
this.$iframe.remove();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this._emit('no-fill', {});
|
|
636
|
+
this.destroy();
|
|
637
|
+
|
|
638
|
+
if (typeof this.options.onExhausted === 'function') {
|
|
639
|
+
this.options.onExhausted();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Remove listeners and timers; the unit is inert afterwards. */
|
|
644
|
+
destroy() {
|
|
645
|
+
this.destroyed = true;
|
|
646
|
+
this._clearFillTimer();
|
|
647
|
+
|
|
648
|
+
if (this._rotateTimer) {
|
|
649
|
+
clearInterval(this._rotateTimer);
|
|
650
|
+
this._rotateTimer = null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
window.removeEventListener('message', this._onMessage);
|
|
654
|
+
window.removeEventListener('online', this._onRecover);
|
|
655
|
+
document.removeEventListener('visibilitychange', this._onRecover);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
_armFillTimer() {
|
|
659
|
+
this._clearFillTimer();
|
|
660
|
+
this._fillTimer = setTimeout(() => {
|
|
661
|
+
// A 204 no-fill (or a dead server) never posts dimensions — end the lane
|
|
662
|
+
if (!this.filled) {
|
|
663
|
+
this.noFill();
|
|
664
|
+
}
|
|
665
|
+
}, this.fillTimeout);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
_clearFillTimer() {
|
|
669
|
+
if (this._fillTimer) {
|
|
670
|
+
clearTimeout(this._fillTimer);
|
|
671
|
+
this._fillTimer = null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
_emit(name, detail) {
|
|
676
|
+
const callback = this.options[`on${name.replace(/(^|-)(\w)/g, (m, sep, ch) => ch.toUpperCase())}`];
|
|
677
|
+
if (typeof callback === 'function') {
|
|
678
|
+
try {
|
|
679
|
+
callback(detail);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
logger.error(`on${name} callback error:`, e);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (typeof CustomEvent !== 'undefined' && typeof this.$el.dispatchEvent === 'function') {
|
|
686
|
+
this.$el.dispatchEvent(new CustomEvent(`omega-vert:${name}`, { detail, bubbles: true }));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
class Verts {
|
|
692
|
+
constructor(manager) {
|
|
693
|
+
this.manager = manager;
|
|
694
|
+
|
|
695
|
+
// The AdSense script loads ONCE — the cached promise keeps a rejection
|
|
696
|
+
// (adblock) sticky for every later unit on the page
|
|
697
|
+
this._adsenseScript = null;
|
|
698
|
+
|
|
699
|
+
// Live promo units + the one observer that re-themes them on a page flip
|
|
700
|
+
this._promoUnits = new Set();
|
|
701
|
+
this._themeObserver = null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* The page's current theme — the frame's theme SSOT. `data-bs-theme` on the
|
|
706
|
+
* root element is what the appearance module stamps; when it says nothing the
|
|
707
|
+
* frame follows the OS through its own prefers-color-scheme branch.
|
|
708
|
+
* @returns {string} 'light' | 'dark', or '' to follow the OS
|
|
709
|
+
*/
|
|
710
|
+
pageTheme() {
|
|
711
|
+
const theme = typeof document !== 'undefined'
|
|
712
|
+
? document.documentElement?.getAttribute('data-bs-theme')
|
|
713
|
+
: null;
|
|
714
|
+
|
|
715
|
+
return theme === 'light' || theme === 'dark' ? theme : '';
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Resolve the in-house ad source to a base URL.
|
|
720
|
+
* @param {string} [source] - override; defaults to advertising.providers.inhouse.source
|
|
721
|
+
* @returns {string|null} base URL (no trailing slash), or null when unconfigured
|
|
722
|
+
*/
|
|
723
|
+
resolveSource(source) {
|
|
724
|
+
const configured = source
|
|
725
|
+
|| this.manager.config.advertising?.providers?.inhouse?.source;
|
|
726
|
+
|
|
727
|
+
if (!configured) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (/^https?:\/\//i.test(configured)) {
|
|
732
|
+
return configured.replace(/\/+$/, '');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (configured === 'self') {
|
|
736
|
+
return this.manager.getApiUrl();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (configured === 'company') {
|
|
740
|
+
const companyUrl = this.manager.config.company?.url;
|
|
741
|
+
if (!companyUrl) {
|
|
742
|
+
logger.warn('inhouse source is "company" but config.company.url is not set');
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
return this.manager.getApiUrl(null, companyUrl);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
logger.warn('Unsupported inhouse source:', configured);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Read a host element's data-omega-vert* attributes into render options. The
|
|
754
|
+
* theme is the one option with a fallback chain: the host's own
|
|
755
|
+
* data-omega-vert-theme pin wins, else the page's theme, else the OS.
|
|
756
|
+
* @param {Element} $el - element carrying the data-omega-vert vocabulary
|
|
757
|
+
* @returns {object} { type, size, vertId, tags, theme }
|
|
758
|
+
*/
|
|
759
|
+
parseElementOptions($el) {
|
|
760
|
+
const attr = (name) => (typeof $el.getAttribute === 'function' && $el.getAttribute(name)) || '';
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
type: attr('data-omega-vert') || 'display',
|
|
764
|
+
size: attr('data-omega-vert-size'),
|
|
765
|
+
vertId: attr('data-omega-vert-id'),
|
|
766
|
+
theme: attr('data-omega-vert-theme') || this.pageTheme(),
|
|
767
|
+
tags: attr('data-omega-vert-tags')
|
|
768
|
+
.split(',')
|
|
769
|
+
.map((tag) => tag.trim())
|
|
770
|
+
.filter(Boolean),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Arm one host element lazily: the ladder runs only near the viewport
|
|
776
|
+
* (IntersectionObserver; no observer support → immediately). Options are
|
|
777
|
+
* read from the element's data-omega-vert* attributes; passed options win.
|
|
778
|
+
* Idempotent — a mounted element never mounts twice.
|
|
779
|
+
* @param {Element} $el - host element
|
|
780
|
+
* @param {object} [options] - overrides merged over the element attributes
|
|
781
|
+
* @returns {Promise<object|null>|null} the render result when armed
|
|
782
|
+
* immediately (no observer), null otherwise (armed lazily or repeat call)
|
|
783
|
+
*/
|
|
784
|
+
mount($el, options = {}) {
|
|
785
|
+
if (!$el || $el.__omegaVertMounted) {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
$el.__omegaVertMounted = true;
|
|
789
|
+
|
|
790
|
+
const merged = { ...this.parseElementOptions($el), ...options };
|
|
791
|
+
|
|
792
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
793
|
+
return this.render($el, merged);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const observer = new IntersectionObserver((entries) => {
|
|
797
|
+
if (!entries.some((entry) => entry.isIntersecting)) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
observer.disconnect();
|
|
801
|
+
this.render($el, merged);
|
|
802
|
+
}, { rootMargin: '200px 0px' });
|
|
803
|
+
|
|
804
|
+
observer.observe($el);
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Auto-bind every [data-omega-vert] element under a root — the surface hook
|
|
810
|
+
* desktop/extension call (phase 4); the web section mounts per element.
|
|
811
|
+
* @param {Element|Document} [root] - scan scope (defaults to document)
|
|
812
|
+
* @returns {Element[]} the elements newly mounted by this call
|
|
813
|
+
*/
|
|
814
|
+
bind(root) {
|
|
815
|
+
const scope = root || (typeof document !== 'undefined' ? document : null);
|
|
816
|
+
if (!scope || typeof scope.querySelectorAll !== 'function') {
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const mounted = [];
|
|
821
|
+
scope.querySelectorAll('[data-omega-vert]').forEach(($el) => {
|
|
822
|
+
if ($el.__omegaVertMounted) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this.mount($el);
|
|
826
|
+
mounted.push($el);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
return mounted;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Run the full ladder into a host element: provider (AdSense) when
|
|
834
|
+
* configured and the type is a provider type, then the fallback lane, then
|
|
835
|
+
* the terminal promo.
|
|
836
|
+
* @param {Element} $el - host element
|
|
837
|
+
* @param {object} [options] - VertUnit options + { type }
|
|
838
|
+
* @returns {Promise<{ lane: string, unit?: VertUnit }>}
|
|
839
|
+
*/
|
|
840
|
+
async render($el, options = {}) {
|
|
841
|
+
const advertising = this.manager.config.advertising || {};
|
|
842
|
+
const adsense = advertising.providers?.adsense;
|
|
843
|
+
const type = options.type || 'display';
|
|
844
|
+
|
|
845
|
+
if (type === 'house') {
|
|
846
|
+
return this.renderHouse($el, options);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const format = ADSENSE_FORMATS[type];
|
|
850
|
+
if (!format) {
|
|
851
|
+
logger.warn('Unsupported ad type:', type);
|
|
852
|
+
return this._fallback($el, options);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// No client id: the provider lane is not attempted at all. Presence of
|
|
856
|
+
// the client id is the ONE adsense switch (#527) — it decides the manager
|
|
857
|
+
// managing the account, these units rendering, and the ads.txt record
|
|
858
|
+
// together, so there is no second gate to read here.
|
|
859
|
+
if (!adsense?.client) {
|
|
860
|
+
return this._fallback($el, options);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Script-load failure IS the adblock detector — no bait divs, no library
|
|
864
|
+
try {
|
|
865
|
+
await this._loadAdSenseScript(adsense.client);
|
|
866
|
+
} catch (e) {
|
|
867
|
+
logger.warn('AdSense script blocked/failed — fallback lane:', e?.message || e);
|
|
868
|
+
return this._fallback($el, options);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const $ins = this._buildIns(adsense, type, format, options);
|
|
872
|
+
const maxHeight = resolveSizePx(options.size);
|
|
873
|
+
if (maxHeight) {
|
|
874
|
+
$el.style.setProperty('max-height', `${maxHeight}px`, 'important');
|
|
875
|
+
$el.style.setProperty('overflow', 'hidden');
|
|
876
|
+
}
|
|
877
|
+
$el.appendChild($ins);
|
|
878
|
+
|
|
879
|
+
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
|
880
|
+
|
|
881
|
+
const status = await this._awaitFill($ins, options.fillTimeout || DEFAULT_FILL_TIMEOUT);
|
|
882
|
+
if (status === 'filled') {
|
|
883
|
+
this._emitHost($el, options, 'fill', { lane: 'provider' });
|
|
884
|
+
return { lane: 'provider' };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// unfilled / timeout → clear the provider markup, fall through
|
|
888
|
+
if (typeof $ins.remove === 'function') {
|
|
889
|
+
$ins.remove();
|
|
890
|
+
}
|
|
891
|
+
return this._fallback($el, options);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Mount the fallback lane directly (house/company inventory) — the lane
|
|
896
|
+
* desktop/extension bind to (no AdSense in those surfaces).
|
|
897
|
+
* @param {Element} $el - host element
|
|
898
|
+
* @param {object} [options] - VertUnit options ({ source } overrides config)
|
|
899
|
+
* @returns {{ lane: string, unit?: VertUnit }} the terminal promo lane when
|
|
900
|
+
* no source resolves
|
|
901
|
+
*/
|
|
902
|
+
renderHouse($el, options = {}) {
|
|
903
|
+
const source = this.resolveSource(options.source);
|
|
904
|
+
|
|
905
|
+
if (!source) {
|
|
906
|
+
return this._exhausted($el, options);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const tags = options.tags?.length
|
|
910
|
+
? options.tags
|
|
911
|
+
: this.manager.config.advertising?.tags || [];
|
|
912
|
+
|
|
913
|
+
// The house frame's own no-fill hands the host to the terminal lane
|
|
914
|
+
const unit = new VertUnit(this.manager, $el, {
|
|
915
|
+
...options,
|
|
916
|
+
source,
|
|
917
|
+
tags,
|
|
918
|
+
onExhausted: () => this.renderPromo($el, options),
|
|
919
|
+
}).load();
|
|
920
|
+
|
|
921
|
+
// The host keeps a handle on its live unit, so a re-mount can tear the
|
|
922
|
+
// old one down first (its listeners and timers outlive the DOM otherwise)
|
|
923
|
+
$el.__omegaVertUnit = unit;
|
|
924
|
+
|
|
925
|
+
return { lane: 'house', unit };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Render the terminal promo into a host: a real unit carrying the built-in
|
|
930
|
+
* promo document by srcdoc, no network. Clears whatever the failed lanes
|
|
931
|
+
* left behind and keeps the unit's reserved size.
|
|
932
|
+
* @param {Element} $el - host element
|
|
933
|
+
* @param {object} [options] - { size, theme } shape the frame; callbacks are
|
|
934
|
+
* emitted
|
|
935
|
+
* @returns {{ lane: string, unit: PromoUnit }}
|
|
936
|
+
*/
|
|
937
|
+
renderPromo($el, options = {}) {
|
|
938
|
+
if (typeof $el.replaceChildren === 'function') {
|
|
939
|
+
$el.replaceChildren();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// A previous lane may have hidden, capped or clipped the host; the promo
|
|
943
|
+
// unit re-applies its own ceiling when the slot is sized
|
|
944
|
+
$el.style.removeProperty('display');
|
|
945
|
+
$el.style.removeProperty('max-height');
|
|
946
|
+
$el.style.removeProperty('overflow');
|
|
947
|
+
|
|
948
|
+
const unit = new PromoUnit(this.manager, $el, options, (name, detail) => {
|
|
949
|
+
this._emitHost($el, options, name, detail);
|
|
950
|
+
}).load();
|
|
951
|
+
|
|
952
|
+
$el.__omegaVertUnit = unit;
|
|
953
|
+
this._promoUnits.add(unit);
|
|
954
|
+
this._watchPageTheme();
|
|
955
|
+
|
|
956
|
+
this._emitHost($el, options, 'promo', {});
|
|
957
|
+
|
|
958
|
+
return { lane: 'promo', unit };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Watch the page's theme attribute once and re-stamp every live promo frame
|
|
963
|
+
* when it flips (a host that pinned its own data-omega-vert-theme keeps it).
|
|
964
|
+
* The house lane is deliberately absent: its document is a server response,
|
|
965
|
+
* so a live re-theme would be a reload.
|
|
966
|
+
*/
|
|
967
|
+
_watchPageTheme() {
|
|
968
|
+
if (this._themeObserver || typeof MutationObserver === 'undefined' || typeof document === 'undefined') {
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
this._themeObserver = new MutationObserver(() => {
|
|
973
|
+
const theme = this.pageTheme();
|
|
974
|
+
|
|
975
|
+
this._promoUnits.forEach((unit) => {
|
|
976
|
+
if (unit.destroyed) {
|
|
977
|
+
this._promoUnits.delete(unit);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const pinned = typeof unit.$el?.getAttribute === 'function' && unit.$el.getAttribute('data-omega-vert-theme');
|
|
982
|
+
if (!pinned) {
|
|
983
|
+
unit.setTheme(theme);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bs-theme'] });
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Provider miss → the configured fallback role, or the terminal lane.
|
|
993
|
+
* @param {Element} $el
|
|
994
|
+
* @param {object} options
|
|
995
|
+
* @returns {Promise<object|null>|object}
|
|
996
|
+
*/
|
|
997
|
+
_fallback($el, options) {
|
|
998
|
+
const fallback = this.manager.config.advertising?.fallback;
|
|
999
|
+
|
|
1000
|
+
if (fallback === 'inhouse') {
|
|
1001
|
+
return this.renderHouse($el, options);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return this._exhausted($el, options);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* The ladder's end — every configured lane failed. no-fill still emits (the
|
|
1009
|
+
* telemetry meaning is unchanged: nothing was sold), then the built-in promo
|
|
1010
|
+
* renders so the unit is never empty.
|
|
1011
|
+
* @param {Element} $el
|
|
1012
|
+
* @param {object} options
|
|
1013
|
+
* @returns {{ lane: string }}
|
|
1014
|
+
*/
|
|
1015
|
+
_exhausted($el, options) {
|
|
1016
|
+
this._emitHost($el, options, 'no-fill', {});
|
|
1017
|
+
return this.renderPromo($el, options);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
_emitHost($el, options, name, detail) {
|
|
1021
|
+
const callback = options[`on${name.replace(/(^|-)(\w)/g, (m, sep, ch) => ch.toUpperCase())}`];
|
|
1022
|
+
if (typeof callback === 'function') {
|
|
1023
|
+
try {
|
|
1024
|
+
callback(detail);
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
logger.error(`on${name} callback error:`, e);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (typeof CustomEvent !== 'undefined' && typeof $el.dispatchEvent === 'function') {
|
|
1031
|
+
$el.dispatchEvent(new CustomEvent(`omega-vert:${name}`, { detail, bubbles: true }));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
_loadAdSenseScript(client) {
|
|
1036
|
+
if (!this._adsenseScript) {
|
|
1037
|
+
this._adsenseScript = this.manager.dom().loadScript({
|
|
1038
|
+
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`,
|
|
1039
|
+
async: true,
|
|
1040
|
+
crossorigin: 'anonymous',
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return this._adsenseScript;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
_buildIns(adsense, type, format, options) {
|
|
1048
|
+
const $ins = document.createElement('ins');
|
|
1049
|
+
$ins.className = 'adsbygoogle';
|
|
1050
|
+
$ins.style.cssText = format.style;
|
|
1051
|
+
$ins.setAttribute('data-ad-client', adsense.client);
|
|
1052
|
+
|
|
1053
|
+
for (const [key, value] of Object.entries(format.attributes)) {
|
|
1054
|
+
$ins.setAttribute(key, value);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// In-feed layout keys — one JS-side table (kills the duplicated include keys)
|
|
1058
|
+
if (format.layoutKeys) {
|
|
1059
|
+
const layoutKey = format.layoutKeys[options.layout || 'image-above'] || format.layoutKeys['image-above'];
|
|
1060
|
+
$ins.setAttribute('data-ad-layout-key', layoutKey);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const slot = adsense[format.slotKey];
|
|
1064
|
+
if (slot) {
|
|
1065
|
+
$ins.setAttribute('data-ad-slot', slot);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return $ins;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Await AdSense fill: a MutationObserver on data-ad-status (+ timeout) —
|
|
1073
|
+
* not the legacy 100 ms poll.
|
|
1074
|
+
* @param {Element} $ins
|
|
1075
|
+
* @param {number} timeout
|
|
1076
|
+
* @returns {Promise<string>} 'filled' | 'unfilled' | 'timeout'
|
|
1077
|
+
*/
|
|
1078
|
+
_awaitFill($ins, timeout) {
|
|
1079
|
+
return new Promise((resolve) => {
|
|
1080
|
+
let observer = null;
|
|
1081
|
+
let timer = null;
|
|
1082
|
+
|
|
1083
|
+
const settle = (status) => {
|
|
1084
|
+
if (observer) {
|
|
1085
|
+
observer.disconnect();
|
|
1086
|
+
}
|
|
1087
|
+
if (timer) {
|
|
1088
|
+
clearTimeout(timer);
|
|
1089
|
+
}
|
|
1090
|
+
resolve(status);
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const check = () => {
|
|
1094
|
+
const status = typeof $ins.getAttribute === 'function' ? $ins.getAttribute('data-ad-status') : null;
|
|
1095
|
+
if (status === 'filled' || status === 'unfilled') {
|
|
1096
|
+
settle(status);
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
return false;
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
if (check()) {
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
1107
|
+
observer = new MutationObserver(() => check());
|
|
1108
|
+
observer.observe($ins, { attributes: true, attributeFilter: ['data-ad-status'] });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
timer = setTimeout(() => settle('timeout'), timeout);
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export default Verts;
|
|
1117
|
+
export {
|
|
1118
|
+
VertUnit,
|
|
1119
|
+
PromoUnit,
|
|
1120
|
+
SIZE_PRESETS,
|
|
1121
|
+
ADSENSE_FORMATS,
|
|
1122
|
+
PROMO_URL,
|
|
1123
|
+
PROMO_ID,
|
|
1124
|
+
promoHref,
|
|
1125
|
+
buildPromo,
|
|
1126
|
+
buildPromoDocument,
|
|
1127
|
+
MESSAGE_DIMENSIONS,
|
|
1128
|
+
MESSAGE_CLICK,
|
|
1129
|
+
IFRAME_SANDBOX,
|
|
1130
|
+
PROMO_SANDBOX,
|
|
1131
|
+
resolveSizePx,
|
|
1132
|
+
clampHeight,
|
|
1133
|
+
};
|