universal-ad-loader 0.1.2 → 0.2.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/CHANGELOG.md +19 -0
- package/README.md +37 -5
- package/dist/index.cjs +363 -46
- package/dist/index.d.ts +46 -4
- package/dist/index.mjs +363 -46
- package/dist/ual.js +363 -46
- package/dist/ual.min.js +24 -5
- package/docs/cdn.md +30 -15
- package/docs/esempio.md +1 -1
- package/package.json +3 -3
- package/schema/ual-config.schema.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
# 0.2.0
|
|
2
|
+
|
|
3
|
+
**What's new**
|
|
4
|
+
- `native` creatives: the ad server sends assets (title, body, image, icon, sponsored-by, call to action, rating, price) and UAL draws
|
|
5
|
+
them in the page inside a Shadow DOM, inheriting the site's font and colour. A "Sponsored" label is always shown. Assets are text,
|
|
6
|
+
never HTML; images and links must be http(s). Layouts: `card`, `list`, `compact`; style with the `--ual-native-*` CSS variables.
|
|
7
|
+
- `video` creatives (out-stream): a muted `<video>` that starts when at least half of it is visible and pauses when it leaves the
|
|
8
|
+
screen, with a sound button. No audio autoplay. New trackers: `trackers.start` and `trackers.quartile.{firstQuartile,midpoint,thirdQuartile,complete}`,
|
|
9
|
+
each fired once, and a `videoEvent` event. Video viewability waits 2 seconds instead of 1 (unless the slot sets `viewability.durationMs`).
|
|
10
|
+
- `UALExt.track(name)`: HTML creatives can fire the named trackers declared in `trackers.custom`. Undeclared names are ignored.
|
|
11
|
+
- `slots.X.sticky`: a small banner anchored to the top or bottom edge with a close button. Only when the ad takes at most 30% of
|
|
12
|
+
the screen height (Better Ads); otherwise it stays in the page flow.
|
|
13
|
+
- `width`/`height` are optional for `native` ads.
|
|
14
|
+
|
|
15
|
+
**Good to know**
|
|
16
|
+
- The old tags and configurations keep working unchanged.
|
|
17
|
+
- Video quartiles use the player's `timeupdate`; the file must have a duration (a live recording without one gives no quartiles).
|
|
18
|
+
- The 2-second video viewability rule is the one commonly used for video; check it against the MRC guideline before relying on it for billing.
|
|
19
|
+
|
|
1
20
|
# 0.1.2
|
|
2
21
|
|
|
3
22
|
**Changes**
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# UAL — Universal Ad Loader
|
|
1
|
+
# UAL — Universal Ad Loader [](https://www.npmjs.com/package/universal-ad-loader) [](https://www.npmjs.com/package/universal-ad-loader) [](https://www.npmjs.com/package/universal-ad-loader) [](https://bundlephobia.com/package/universal-ad-loader) [](LICENSE)
|
|
2
2
|
|
|
3
3
|
One tag per site. It handles slots, centralized configuration, TCF consent, lazy loading, waterfalls across providers (adapters), sandboxed rendering, viewability and events. ~10 KB gzip.
|
|
4
4
|
|
|
5
5
|
## Installing on a site
|
|
6
6
|
|
|
7
7
|
```html
|
|
8
|
-
<script async src="https://
|
|
8
|
+
<script async src="https://cdn.jsdelivr.net/npm/universal-ad-loader@0.1.2/dist/ual.min.js"
|
|
9
9
|
data-config="https://adcdn.yourdomain.net/config/site_123.json"></script>
|
|
10
10
|
|
|
11
11
|
<div data-ad-slot="top"></div>
|
|
@@ -29,7 +29,7 @@ One tag per site. It handles slots, centralized configuration, TCF consent, lazy
|
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
</script>
|
|
32
|
-
<script async src="https://
|
|
32
|
+
<script async src="https://cdn.jsdelivr.net/npm/universal-ad-loader@0.1.2/dist/ual.min.js"></script>
|
|
33
33
|
|
|
34
34
|
<div data-ad-slot="top"></div>
|
|
35
35
|
```
|
|
@@ -37,8 +37,9 @@ One tag per site. It handles slots, centralized configuration, TCF consent, lazy
|
|
|
37
37
|
- Use either `data-config` or `init(...)`, not both: a second `init` is ignored with a warning.
|
|
38
38
|
- The configuration lives in the page HTML, so changing it means redeploying the page (with a JSON on the CDN you just replace the file). `u.init('https://.../site_123.json')` also works if you want to choose the URL at runtime.
|
|
39
39
|
- An inline script needs `'unsafe-inline'` or a nonce in the page's CSP; with a strict CSP prefer the external JSON.
|
|
40
|
+
- **Components (React, Next.js, Vue, Astro):** the same applies, and inline is already the default. The `config` prop takes an object (inline) or the URL of a JSON; Astro's `UalBundled` writes the object into an `application/json` block, which works even with a strict CSP. See `docs/libreria.md`.
|
|
40
41
|
|
|
41
|
-
> **
|
|
42
|
+
> **Where the files live:** the loader is served for free by [jsDelivr](https://www.jsdelivr.com/) straight from the npm package (nothing to upload; pin the version in the URL, as above). Only the configuration JSON, one per site, goes on your own CDN with the right headers: see `docs/cdn.md`.
|
|
42
43
|
|
|
43
44
|
## Configuration (JSON served from the CDN)
|
|
44
45
|
|
|
@@ -123,6 +124,36 @@ Messages are accepted only from the slot's own iframe (checked on `event.source`
|
|
|
123
124
|
- `src` (image), `url` (url), `clickUrl` and all `trackers`: only `http:`/`https:` (and `data:image/` for `src`). No `javascript:`.
|
|
124
125
|
- Sandbox: never `allow-same-origin` for `image`/`html`; never `allow-top-navigation` (a creative cannot redirect the page; `target=_blank` links open a popup). For `type:'url'`, `allow-same-origin` is kept (legacy servers need it) **only if the URL has a different origin from the page**; if it is the same origin it is removed. A creative's `sandbox` override is filtered with the same rules.
|
|
125
126
|
|
|
127
|
+
## Native, video and sticky
|
|
128
|
+
|
|
129
|
+
Besides `image`, `html` and `url`, an ad can be `native` or `video`.
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{ "slotId": "feed#1", "type": "native", "clickUrl": "https://ads/c?t=SIGNED",
|
|
133
|
+
"native": { "title": "A headline", "body": "Two lines of text", "sponsoredBy": "ACME",
|
|
134
|
+
"cta": "Learn more", "image": { "src": "https://cdn/.../main.jpg", "width": 1200, "height": 628 },
|
|
135
|
+
"icon": "https://cdn/.../icon.png", "layout": "card" },
|
|
136
|
+
"trackers": { "impression": ["https://ads/i?t=..."], "viewable": ["https://ads/v?t=..."] } }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The assets are plain data (never HTML). UAL draws them in the page inside a Shadow DOM, so the card inherits the site's font and colour;
|
|
140
|
+
a "Sponsored" label is always shown. Style it with CSS variables on the slot: `--ual-native-bg`, `--ual-native-border`,
|
|
141
|
+
`--ual-native-radius`, `--ual-native-accent`, `--ual-native-accent-text`. Title is limited to 150 characters, body to 300 (longer text is cut).
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{ "slotId": "article#1", "type": "video", "src": "https://cdn/.../ad.mp4", "mime": "video/mp4", "poster": "https://cdn/.../p.jpg",
|
|
145
|
+
"width": 640, "height": 360, "clickUrl": "https://ads/c?t=SIGNED",
|
|
146
|
+
"trackers": { "impression": ["..."], "viewable": ["..."], "start": ["..."],
|
|
147
|
+
"quartile": { "firstQuartile": ["..."], "midpoint": ["..."], "thirdQuartile": ["..."], "complete": ["..."] } } }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The video starts **muted** when at least half of it is visible and pauses when it is not; the visitor can turn the sound on. Each tracker fires once.
|
|
151
|
+
The `videoEvent` event reports `start`, `firstQuartile`, `midpoint`, `thirdQuartile` and `complete`. Video is counted as viewable after 2 s in view (set `viewability.durationMs` on the slot to change it).
|
|
152
|
+
|
|
153
|
+
`UALExt.track(name)` lets an HTML creative fire a named tracker declared in `trackers.custom` (`{ "engaged": ["https://ads/e?t=..."] }`); names that were not declared are ignored.
|
|
154
|
+
|
|
155
|
+
`slots.X.sticky` (`true`, `"top"`, `"bottom"` or `{ "position": "bottom" }`) anchors a small banner to the screen edge with a close button. It only applies if the ad is at most 30% of the screen height; larger ads stay in the page.
|
|
156
|
+
|
|
126
157
|
## JavaScript API
|
|
127
158
|
|
|
128
159
|
```js
|
|
@@ -209,7 +240,7 @@ To work on one part at a time (this is what CI does, in parallel jobs):
|
|
|
209
240
|
|---|---|---|
|
|
210
241
|
| `npm run lint` | oxlint on the sources | base dependencies only |
|
|
211
242
|
| `npm run test:node` | `test/package.mjs` | base dependencies only (no browser) |
|
|
212
|
-
| `npm run test:browser` | `test/run.mjs` and `test/
|
|
243
|
+
| `npm run test:browser` | `test/run.mjs`, `test/regression.mjs` and `test/formats.mjs` | + Chromium |
|
|
213
244
|
| `npm run test:frameworks` | `test/wrappers.mjs` | + `test/heavy` and Chromium |
|
|
214
245
|
|
|
215
246
|
Every suite also writes a JUnit report to `test-results/` (CI shows it in the Tests tab).
|
|
@@ -218,6 +249,7 @@ Every suite also writes a JUnit report to `test-results/` (CI shows it in the Te
|
|
|
218
249
|
|---|---|
|
|
219
250
|
| `test/run.mjs` | loader e2e with a fake ad server and a fake CMP: batching per provider, waterfall to house, lazy load, `sizeMapping`, consent, `pageRules`, trackers, expand, sandbox |
|
|
220
251
|
| `test/regression.mjs` | render security, `setTargeting`, late CMP, `contextual` providers, slots moved/closed in the DOM, refresh, `pageView`, the `gpt` adapter (with a `googletag` stub), the `iframe` adapter with the existing systems |
|
|
252
|
+
| `test/formats.mjs` | `native`, `video` (muted autoplay when visible, pause, quartiles once each, sound button), `UALExt.track` and `sticky`, in Chromium with a real video file; plus the validation rules |
|
|
221
253
|
| `test/package.mjs` | the package: import in Node (ESM and CJS) without `window`, server rendering of React and Vue, `exports` map, tarball contents (`npm pack`), types (correct usages compile, wrong ones fail) |
|
|
222
254
|
| `test/wrappers.mjs` | the components in a real browser, with server rendering and delayed hydration: React (in the bundle, from CDN, Next version), Vue, **Next.js 16** (App Router, production build) and **Astro 7** (View Transitions); plus a check that demonstrates the problem the components avoid |
|
|
223
255
|
|
package/dist/index.cjs
CHANGED
|
@@ -299,6 +299,247 @@ function normalize(tc, opts) {
|
|
|
299
299
|
return out;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
// src/core/native.js
|
|
303
|
+
var NATIVE_LIMITS = { title: 150, body: 300, sponsoredBy: 60, cta: 30, displayUrl: 100, price: 30, label: 20 };
|
|
304
|
+
var NATIVE_LAYOUTS = ["card", "list", "compact"];
|
|
305
|
+
var clip = (v, n) => typeof v === "string" ? v.trim().slice(0, n) : "";
|
|
306
|
+
function normalizeNative(raw, safeUrl2) {
|
|
307
|
+
if (!raw || typeof raw !== "object") return { ok: false, reason: "native mancante" };
|
|
308
|
+
const title = clip(raw.title, NATIVE_LIMITS.title);
|
|
309
|
+
if (!title) return { ok: false, reason: "native: titolo mancante" };
|
|
310
|
+
const img = (v) => {
|
|
311
|
+
const o = typeof v === "string" ? { src: v } : v;
|
|
312
|
+
const src = o && typeof o === "object" ? safeUrl2(o.src, true) : null;
|
|
313
|
+
if (!src) return null;
|
|
314
|
+
const dim2 = (n) => {
|
|
315
|
+
const x = Math.round(Number(n));
|
|
316
|
+
return x > 0 && x <= 2e3 ? x : 0;
|
|
317
|
+
};
|
|
318
|
+
return { src, width: dim2(o.width), height: dim2(o.height) };
|
|
319
|
+
};
|
|
320
|
+
const rating = Number(raw.rating);
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
native: {
|
|
324
|
+
title,
|
|
325
|
+
body: clip(raw.body, NATIVE_LIMITS.body),
|
|
326
|
+
sponsoredBy: clip(raw.sponsoredBy, NATIVE_LIMITS.sponsoredBy),
|
|
327
|
+
cta: clip(raw.cta, NATIVE_LIMITS.cta),
|
|
328
|
+
displayUrl: clip(raw.displayUrl, NATIVE_LIMITS.displayUrl),
|
|
329
|
+
price: clip(raw.price, NATIVE_LIMITS.price),
|
|
330
|
+
rating: Number.isFinite(rating) && rating >= 0 && rating <= 5 ? rating : null,
|
|
331
|
+
image: img(raw.image),
|
|
332
|
+
icon: img(raw.icon),
|
|
333
|
+
layout: NATIVE_LAYOUTS.includes(raw.layout) ? raw.layout : "card",
|
|
334
|
+
label: clip(raw.label, NATIVE_LIMITS.label) || "Sponsored"
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
var STYLE = `
|
|
339
|
+
:host{display:block;max-width:100%;font:inherit;color:inherit;line-height:1.35}
|
|
340
|
+
*{box-sizing:border-box}
|
|
341
|
+
.card{display:flex;flex-direction:column;gap:.5em;padding:.75em;text-decoration:none;color:inherit;border:1px solid var(--ual-native-border,rgba(128,128,128,.35));border-radius:var(--ual-native-radius,8px);background:var(--ual-native-bg,transparent);position:relative}
|
|
342
|
+
a.card:hover .title{text-decoration:underline}
|
|
343
|
+
.label{font-size:.72em;letter-spacing:.04em;text-transform:uppercase;opacity:.75}
|
|
344
|
+
.main{display:block;width:100%;height:auto;border-radius:calc(var(--ual-native-radius,8px) - 2px);background:rgba(128,128,128,.12)}
|
|
345
|
+
.row{display:flex;gap:.6em;align-items:flex-start}
|
|
346
|
+
.icon{width:2.4em;height:2.4em;flex:none;border-radius:6px;object-fit:cover}
|
|
347
|
+
.title{font-weight:700;font-size:1.05em}
|
|
348
|
+
.body{opacity:.9;font-size:.92em}
|
|
349
|
+
.meta{font-size:.8em;opacity:.7}
|
|
350
|
+
.cta{align-self:flex-start;font-weight:600;font-size:.9em;padding:.35em .8em;border-radius:999px;background:var(--ual-native-accent,#2563eb);color:var(--ual-native-accent-text,#fff)}
|
|
351
|
+
.price{font-weight:600}
|
|
352
|
+
.layout-list{flex-direction:row;align-items:flex-start}
|
|
353
|
+
.layout-list .main{width:35%;max-width:180px;flex:none}
|
|
354
|
+
.layout-list .text{display:flex;flex-direction:column;gap:.35em;min-width:0}
|
|
355
|
+
.layout-compact .main{display:none}
|
|
356
|
+
`;
|
|
357
|
+
function el(tag, cls, text) {
|
|
358
|
+
const e = document.createElement(tag);
|
|
359
|
+
if (cls) e.className = cls;
|
|
360
|
+
if (text != null) e.textContent = text;
|
|
361
|
+
return e;
|
|
362
|
+
}
|
|
363
|
+
function image(i, cls, alt) {
|
|
364
|
+
const im = el("img", cls);
|
|
365
|
+
im.src = i.src;
|
|
366
|
+
im.alt = alt || "";
|
|
367
|
+
im.loading = "lazy";
|
|
368
|
+
im.referrerPolicy = "strict-origin-when-cross-origin";
|
|
369
|
+
if (i.width && i.height) {
|
|
370
|
+
im.width = i.width;
|
|
371
|
+
im.height = i.height;
|
|
372
|
+
}
|
|
373
|
+
return im;
|
|
374
|
+
}
|
|
375
|
+
function renderNative(container, ad, { onClick } = {}) {
|
|
376
|
+
const n = ad.native;
|
|
377
|
+
const host = el("div", "ual-native");
|
|
378
|
+
const root = host.attachShadow ? host.attachShadow({ mode: "open" }) : host;
|
|
379
|
+
const style = document.createElement("style");
|
|
380
|
+
style.textContent = STYLE;
|
|
381
|
+
root.appendChild(style);
|
|
382
|
+
const card = ad.clickUrl ? el("a", `card layout-${n.layout}`) : el("div", `card layout-${n.layout}`);
|
|
383
|
+
if (ad.clickUrl) {
|
|
384
|
+
card.href = ad.clickUrl;
|
|
385
|
+
card.target = "_blank";
|
|
386
|
+
card.rel = "noopener noreferrer sponsored nofollow";
|
|
387
|
+
card.addEventListener("click", () => {
|
|
388
|
+
if (onClick) onClick();
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
card.appendChild(el("span", "label", n.label));
|
|
392
|
+
if (n.image) card.appendChild(image(n.image, "main", n.title));
|
|
393
|
+
const text = el("div", "text");
|
|
394
|
+
const head = el("div", "row");
|
|
395
|
+
if (n.icon) head.appendChild(image(n.icon, "icon", ""));
|
|
396
|
+
const titles = el("div");
|
|
397
|
+
titles.appendChild(el("div", "title", n.title));
|
|
398
|
+
if (n.sponsoredBy) titles.appendChild(el("div", "meta", n.sponsoredBy));
|
|
399
|
+
head.appendChild(titles);
|
|
400
|
+
text.appendChild(head);
|
|
401
|
+
if (n.body) text.appendChild(el("div", "body", n.body));
|
|
402
|
+
const meta = [n.displayUrl, n.rating !== null ? `\u2605 ${n.rating}` : ""].filter(Boolean).join(" \xB7 ");
|
|
403
|
+
if (meta) text.appendChild(el("div", "meta", meta));
|
|
404
|
+
if (n.price) text.appendChild(el("div", "price", n.price));
|
|
405
|
+
if (n.cta) text.appendChild(el("span", "cta", n.cta));
|
|
406
|
+
card.appendChild(text);
|
|
407
|
+
root.appendChild(card);
|
|
408
|
+
container.innerHTML = "";
|
|
409
|
+
container.appendChild(host);
|
|
410
|
+
return { el: host, destroy: () => host.remove() };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/core/video.js
|
|
414
|
+
var QUARTILES = [["firstQuartile", 0.25], ["midpoint", 0.5], ["thirdQuartile", 0.75]];
|
|
415
|
+
var QUARTILE_NAMES = ["firstQuartile", "midpoint", "thirdQuartile", "complete"];
|
|
416
|
+
var PLAY_THRESHOLD = 0.5;
|
|
417
|
+
function quartileHits(fired, currentTime, duration) {
|
|
418
|
+
if (!(duration > 0) || !(currentTime >= 0)) return [];
|
|
419
|
+
const f = currentTime / duration;
|
|
420
|
+
const hits = [];
|
|
421
|
+
for (const [name, at] of QUARTILES) {
|
|
422
|
+
if (f >= at && !fired.has(name)) {
|
|
423
|
+
fired.add(name);
|
|
424
|
+
hits.push(name);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return hits;
|
|
428
|
+
}
|
|
429
|
+
function normalizeVideo(raw, safeUrl2) {
|
|
430
|
+
const src = safeUrl2(raw.src);
|
|
431
|
+
if (!src) return { ok: false, reason: "video: src non valido (ammessi solo http e https)" };
|
|
432
|
+
return { ok: true, video: { src, poster: safeUrl2(raw.poster, true) || "", mime: typeof raw.mime === "string" && /^video\/[\w.+-]+$/.test(raw.mime) ? raw.mime : "" } };
|
|
433
|
+
}
|
|
434
|
+
function btn(label) {
|
|
435
|
+
const b = document.createElement("button");
|
|
436
|
+
b.type = "button";
|
|
437
|
+
b.textContent = label;
|
|
438
|
+
b.style.cssText = "position:absolute;right:8px;bottom:8px;z-index:2;padding:4px 10px;border:0;border-radius:999px;background:rgba(0,0,0,.65);color:#fff;font:600 12px/1.4 system-ui,sans-serif;cursor:pointer";
|
|
439
|
+
return b;
|
|
440
|
+
}
|
|
441
|
+
function renderVideo(container, ad, { onClick, onEvent, onError } = {}) {
|
|
442
|
+
const wrap = document.createElement("div");
|
|
443
|
+
wrap.className = "ual-video";
|
|
444
|
+
wrap.style.cssText = `position:relative;display:block;margin:0 auto;width:${ad.width}px;max-width:100%;aspect-ratio:${ad.width}/${ad.height};background:#000;overflow:hidden`;
|
|
445
|
+
const v = document.createElement("video");
|
|
446
|
+
v.muted = true;
|
|
447
|
+
v.defaultMuted = true;
|
|
448
|
+
v.playsInline = true;
|
|
449
|
+
v.setAttribute("playsinline", "");
|
|
450
|
+
v.preload = "metadata";
|
|
451
|
+
v.disablePictureInPicture = true;
|
|
452
|
+
v.controls = false;
|
|
453
|
+
v.style.cssText = "display:block;width:100%;height:100%;object-fit:contain";
|
|
454
|
+
if (ad.video.poster) v.poster = ad.video.poster;
|
|
455
|
+
const source = document.createElement("source");
|
|
456
|
+
source.src = ad.video.src;
|
|
457
|
+
if (ad.video.mime) source.type = ad.video.mime;
|
|
458
|
+
v.appendChild(source);
|
|
459
|
+
wrap.appendChild(v);
|
|
460
|
+
const label = document.createElement("span");
|
|
461
|
+
label.textContent = "Advertisement";
|
|
462
|
+
label.style.cssText = "position:absolute;left:8px;top:8px;z-index:2;padding:2px 6px;border-radius:4px;background:rgba(0,0,0,.55);color:#fff;font:600 11px/1.4 system-ui,sans-serif";
|
|
463
|
+
wrap.appendChild(label);
|
|
464
|
+
if (ad.clickUrl) {
|
|
465
|
+
const a = document.createElement("a");
|
|
466
|
+
a.href = ad.clickUrl;
|
|
467
|
+
a.target = "_blank";
|
|
468
|
+
a.rel = "noopener noreferrer sponsored nofollow";
|
|
469
|
+
a.setAttribute("aria-label", "Open the advertiser page");
|
|
470
|
+
a.style.cssText = "position:absolute;inset:0;z-index:1";
|
|
471
|
+
a.addEventListener("click", () => {
|
|
472
|
+
if (onClick) onClick();
|
|
473
|
+
});
|
|
474
|
+
wrap.appendChild(a);
|
|
475
|
+
}
|
|
476
|
+
const sound = btn("Sound on");
|
|
477
|
+
sound.setAttribute("aria-label", "Turn sound on");
|
|
478
|
+
sound.addEventListener("click", () => {
|
|
479
|
+
v.muted = !v.muted;
|
|
480
|
+
sound.textContent = v.muted ? "Sound on" : "Sound off";
|
|
481
|
+
sound.setAttribute("aria-label", v.muted ? "Turn sound on" : "Turn sound off");
|
|
482
|
+
});
|
|
483
|
+
wrap.appendChild(sound);
|
|
484
|
+
const trackers = ad.trackers || {};
|
|
485
|
+
const fired = /* @__PURE__ */ new Set();
|
|
486
|
+
let started = false;
|
|
487
|
+
const emit = (name, urls) => {
|
|
488
|
+
fire(urls);
|
|
489
|
+
if (onEvent) onEvent(name);
|
|
490
|
+
};
|
|
491
|
+
v.addEventListener("playing", () => {
|
|
492
|
+
if (!started) {
|
|
493
|
+
started = true;
|
|
494
|
+
emit("start", trackers.start);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
v.addEventListener("timeupdate", () => {
|
|
498
|
+
quartileHits(fired, v.currentTime, v.duration).forEach((n) => emit(n, trackers.quartile && trackers.quartile[n]));
|
|
499
|
+
});
|
|
500
|
+
v.addEventListener("ended", () => {
|
|
501
|
+
quartileHits(fired, v.duration, v.duration).forEach((n) => emit(n, trackers.quartile && trackers.quartile[n]));
|
|
502
|
+
if (!fired.has("complete")) {
|
|
503
|
+
fired.add("complete");
|
|
504
|
+
emit("complete", trackers.quartile && trackers.quartile.complete);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
v.addEventListener("error", () => {
|
|
508
|
+
if (onError) onError(new Error("video: file non riproducibile"));
|
|
509
|
+
});
|
|
510
|
+
let inView = false;
|
|
511
|
+
const sync = () => {
|
|
512
|
+
if (v.ended) return;
|
|
513
|
+
if (inView && document.visibilityState === "visible") {
|
|
514
|
+
const p = v.play();
|
|
515
|
+
if (p && p.catch) p.catch(() => {
|
|
516
|
+
});
|
|
517
|
+
} else v.pause();
|
|
518
|
+
};
|
|
519
|
+
const io = "IntersectionObserver" in window ? new IntersectionObserver((entries) => {
|
|
520
|
+
entries.forEach((en) => {
|
|
521
|
+
inView = en.intersectionRatio >= PLAY_THRESHOLD;
|
|
522
|
+
});
|
|
523
|
+
sync();
|
|
524
|
+
}, { threshold: [0, PLAY_THRESHOLD, 1] }) : null;
|
|
525
|
+
if (io) io.observe(wrap);
|
|
526
|
+
document.addEventListener("visibilitychange", sync);
|
|
527
|
+
container.innerHTML = "";
|
|
528
|
+
container.appendChild(wrap);
|
|
529
|
+
return {
|
|
530
|
+
el: wrap,
|
|
531
|
+
destroy: () => {
|
|
532
|
+
if (io) io.disconnect();
|
|
533
|
+
document.removeEventListener("visibilitychange", sync);
|
|
534
|
+
v.pause();
|
|
535
|
+
v.removeAttribute("src");
|
|
536
|
+
source.remove();
|
|
537
|
+
v.load();
|
|
538
|
+
wrap.remove();
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
302
543
|
// src/core/render.js
|
|
303
544
|
var DEFAULT_SANDBOX = "allow-scripts allow-popups allow-popups-to-escape-sandbox";
|
|
304
545
|
var MAX_DIM = 2e3;
|
|
@@ -321,12 +562,20 @@ function safeUrl(u, allowDataImage) {
|
|
|
321
562
|
function cleanList(list) {
|
|
322
563
|
return (Array.isArray(list) ? list : []).map((u) => safeUrl(u)).filter(Boolean);
|
|
323
564
|
}
|
|
565
|
+
function cleanCustom(custom) {
|
|
566
|
+
const out = {};
|
|
567
|
+
if (!custom || typeof custom !== "object") return out;
|
|
568
|
+
Object.keys(custom).slice(0, 20).forEach((name) => {
|
|
569
|
+
if (/^[\w-]{1,40}$/.test(name)) out[name] = cleanList(custom[name]);
|
|
570
|
+
});
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
324
573
|
function normalizeAd(raw) {
|
|
325
574
|
if (!raw || typeof raw !== "object") return { ok: false, reason: "annuncio non valido" };
|
|
326
575
|
const type = raw.type || (raw.url ? "url" : raw.src ? "image" : "html");
|
|
327
576
|
const width = dim(raw.width);
|
|
328
577
|
const height = dim(raw.height);
|
|
329
|
-
if (!width || !height) return { ok: false, reason: `dimensioni non valide (${raw.width}x${raw.height}, max ${MAX_DIM})` };
|
|
578
|
+
if (type !== "native" && (!width || !height)) return { ok: false, reason: `dimensioni non valide (${raw.width}x${raw.height}, max ${MAX_DIM})` };
|
|
330
579
|
const t = raw.trackers || {};
|
|
331
580
|
const ad = {
|
|
332
581
|
type,
|
|
@@ -334,7 +583,14 @@ function normalizeAd(raw) {
|
|
|
334
583
|
height,
|
|
335
584
|
alt: raw.alt == null ? void 0 : String(raw.alt),
|
|
336
585
|
clickUrl: safeUrl(raw.clickUrl) || "",
|
|
337
|
-
trackers: {
|
|
586
|
+
trackers: {
|
|
587
|
+
impression: cleanList(t.impression),
|
|
588
|
+
viewable: cleanList(t.viewable),
|
|
589
|
+
click: cleanList(t.click),
|
|
590
|
+
start: cleanList(t.start),
|
|
591
|
+
quartile: Object.fromEntries(QUARTILE_NAMES.map((n) => [n, cleanList(t.quartile && t.quartile[n])])),
|
|
592
|
+
custom: cleanCustom(t.custom)
|
|
593
|
+
},
|
|
338
594
|
sandbox: typeof raw.sandbox === "string" ? raw.sandbox : void 0
|
|
339
595
|
};
|
|
340
596
|
if (type === "image") {
|
|
@@ -346,6 +602,14 @@ function normalizeAd(raw) {
|
|
|
346
602
|
} else if (type === "html") {
|
|
347
603
|
if (typeof raw.html !== "string" || !raw.html) return { ok: false, reason: "html mancante" };
|
|
348
604
|
ad.html = raw.html;
|
|
605
|
+
} else if (type === "native") {
|
|
606
|
+
const n = normalizeNative(raw.native, safeUrl);
|
|
607
|
+
if (!n.ok) return n;
|
|
608
|
+
ad.native = n.native;
|
|
609
|
+
} else if (type === "video") {
|
|
610
|
+
const v = normalizeVideo(raw, safeUrl);
|
|
611
|
+
if (!v.ok) return v;
|
|
612
|
+
ad.video = v.video;
|
|
349
613
|
} else {
|
|
350
614
|
return { ok: false, reason: "tipo sconosciuto: " + type };
|
|
351
615
|
}
|
|
@@ -380,7 +644,8 @@ window.UALExt={
|
|
|
380
644
|
expand:function(w,h){s({cmd:'resize',w:w,h:h,expanded:true});},
|
|
381
645
|
collapse:function(){s({cmd:'collapse'});},
|
|
382
646
|
close:function(){s({cmd:'close'});},
|
|
383
|
-
click:function(){s({cmd:'click'});if(window.clickTag)window.open(window.clickTag,'_blank');}
|
|
647
|
+
click:function(){s({cmd:'click'});if(window.clickTag)window.open(window.clickTag,'_blank');},
|
|
648
|
+
track:function(n){s({cmd:'track',name:String(n)});}
|
|
384
649
|
};
|
|
385
650
|
})();</script>`;
|
|
386
651
|
}
|
|
@@ -423,6 +688,11 @@ function renderInFrame(container, ad, { slotId, onClick, onClose } = {}) {
|
|
|
423
688
|
fire(ad.trackers && ad.trackers.click);
|
|
424
689
|
if (onClick) onClick();
|
|
425
690
|
},
|
|
691
|
+
// UALExt.track(nome): solo i nomi dichiarati dall'ad server nella risposta
|
|
692
|
+
onTrack: (name) => {
|
|
693
|
+
const custom = ad.trackers && ad.trackers.custom;
|
|
694
|
+
if (typeof name === "string" && custom && Object.hasOwn(custom, name)) fire(custom[name]);
|
|
695
|
+
},
|
|
426
696
|
onClose
|
|
427
697
|
});
|
|
428
698
|
container.innerHTML = "";
|
|
@@ -458,12 +728,42 @@ function onMessage(e) {
|
|
|
458
728
|
case "click":
|
|
459
729
|
f.onClick();
|
|
460
730
|
break;
|
|
731
|
+
case "track":
|
|
732
|
+
f.onTrack(d.name);
|
|
733
|
+
break;
|
|
461
734
|
default:
|
|
462
735
|
}
|
|
463
736
|
}
|
|
464
737
|
|
|
738
|
+
// src/core/sticky.js
|
|
739
|
+
var STICKY_MAX_SCREEN_SHARE = 0.3;
|
|
740
|
+
var STICKY_POSITIONS = ["bottom", "top"];
|
|
741
|
+
function stickyAllowed(adHeight, viewportHeight) {
|
|
742
|
+
return adHeight > 0 && viewportHeight > 0 && adHeight <= viewportHeight * STICKY_MAX_SCREEN_SHARE;
|
|
743
|
+
}
|
|
744
|
+
function stickyPosition(cfg) {
|
|
745
|
+
if (!cfg) return null;
|
|
746
|
+
const p = typeof cfg === "string" ? cfg : cfg && typeof cfg === "object" ? cfg.position : "bottom";
|
|
747
|
+
return STICKY_POSITIONS.includes(p) ? p : "bottom";
|
|
748
|
+
}
|
|
749
|
+
function applySticky(el2, position, onClose) {
|
|
750
|
+
const saved = el2.style.cssText;
|
|
751
|
+
el2.style.cssText += `;position:fixed;left:0;right:0;${position}:0;z-index:2147483000;text-align:center;margin:0;padding:0`;
|
|
752
|
+
const close = document.createElement("button");
|
|
753
|
+
close.type = "button";
|
|
754
|
+
close.textContent = "\xD7";
|
|
755
|
+
close.setAttribute("aria-label", "Close advertisement");
|
|
756
|
+
close.style.cssText = `position:absolute;right:6px;${position === "bottom" ? "top" : "bottom"}:-26px;width:26px;height:26px;border:0;border-radius:6px 6px 0 0;background:rgba(0,0,0,.7);color:#fff;font:16px/26px system-ui,sans-serif;cursor:pointer`;
|
|
757
|
+
close.addEventListener("click", onClose);
|
|
758
|
+
el2.appendChild(close);
|
|
759
|
+
return () => {
|
|
760
|
+
el2.style.cssText = saved;
|
|
761
|
+
close.remove();
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
465
765
|
// src/core/viewability.js
|
|
466
|
-
function watchViewability(
|
|
766
|
+
function watchViewability(el2, { threshold = 0.5, durationMs = 1e3 } = {}, onViewable) {
|
|
467
767
|
if (!("IntersectionObserver" in window)) return () => {
|
|
468
768
|
};
|
|
469
769
|
let timer = null;
|
|
@@ -490,7 +790,7 @@ function watchViewability(el, { threshold = 0.5, durationMs = 1e3 } = {}, onView
|
|
|
490
790
|
}, { threshold: [0, threshold, 1] });
|
|
491
791
|
const onVis = () => document.visibilityState === "visible" && inView ? start() : stop();
|
|
492
792
|
document.addEventListener("visibilitychange", onVis);
|
|
493
|
-
io.observe(
|
|
793
|
+
io.observe(el2);
|
|
494
794
|
function cleanup() {
|
|
495
795
|
stop();
|
|
496
796
|
io.disconnect();
|
|
@@ -584,50 +884,50 @@ function createEngine(adapterRegistry) {
|
|
|
584
884
|
function scan(root) {
|
|
585
885
|
const list = [];
|
|
586
886
|
if (root.nodeType === 1 && root.matches && root.matches("[data-ad-slot]")) list.push(root);
|
|
587
|
-
if (root.querySelectorAll) root.querySelectorAll("[data-ad-slot]").forEach((
|
|
887
|
+
if (root.querySelectorAll) root.querySelectorAll("[data-ad-slot]").forEach((el2) => list.push(el2));
|
|
588
888
|
list.forEach(maybeRegister);
|
|
589
889
|
}
|
|
590
|
-
function maybeRegister(
|
|
591
|
-
const id =
|
|
592
|
-
if (!id) return register(
|
|
890
|
+
function maybeRegister(el2) {
|
|
891
|
+
const id = el2.dataset.ualId;
|
|
892
|
+
if (!id) return register(el2);
|
|
593
893
|
if (slots.has(id)) return;
|
|
594
|
-
if (FINAL_STATUS.has(
|
|
595
|
-
resetEl(
|
|
596
|
-
register(
|
|
894
|
+
if (FINAL_STATUS.has(el2.dataset.ualStatus)) return;
|
|
895
|
+
resetEl(el2);
|
|
896
|
+
register(el2);
|
|
597
897
|
}
|
|
598
|
-
function resetEl(
|
|
599
|
-
delete
|
|
600
|
-
delete
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
898
|
+
function resetEl(el2) {
|
|
899
|
+
delete el2.dataset.ualId;
|
|
900
|
+
delete el2.dataset.ualStatus;
|
|
901
|
+
el2.querySelectorAll(":scope > .ual-label, :scope > .ual-creative").forEach((n) => n.remove());
|
|
902
|
+
el2.classList.remove("ual-slot", "ual-filled");
|
|
903
|
+
el2.style.display = "";
|
|
904
|
+
el2.style.minHeight = "";
|
|
605
905
|
}
|
|
606
|
-
function register(
|
|
607
|
-
const name =
|
|
906
|
+
function register(el2) {
|
|
907
|
+
const name = el2.getAttribute("data-ad-slot");
|
|
608
908
|
const slotCfg = cfg.slots[name];
|
|
609
|
-
|
|
909
|
+
el2.dataset.ualId = name + "#" + ++seq2;
|
|
610
910
|
if (!slotCfg) {
|
|
611
911
|
log("Slot non configurato, ignorato:", name);
|
|
612
|
-
|
|
912
|
+
el2.dataset.ualStatus = "unconfigured";
|
|
613
913
|
return;
|
|
614
914
|
}
|
|
615
915
|
if (slotCfg.enabled === false) {
|
|
616
916
|
log("Slot disabilitato:", name);
|
|
617
|
-
|
|
618
|
-
collapse(
|
|
917
|
+
el2.dataset.ualStatus = "disabled";
|
|
918
|
+
collapse(el2, slotCfg);
|
|
619
919
|
return;
|
|
620
920
|
}
|
|
621
921
|
let params = {};
|
|
622
922
|
try {
|
|
623
|
-
params =
|
|
923
|
+
params = el2.dataset.adParams ? JSON.parse(el2.dataset.adParams) : {};
|
|
624
924
|
} catch (e) {
|
|
625
|
-
warn("data-ad-params non \xE8 JSON valido",
|
|
925
|
+
warn("data-ad-params non \xE8 JSON valido", el2);
|
|
626
926
|
}
|
|
627
927
|
const slot = {
|
|
628
|
-
instanceId:
|
|
928
|
+
instanceId: el2.dataset.ualId,
|
|
629
929
|
name,
|
|
630
|
-
el,
|
|
930
|
+
el: el2,
|
|
631
931
|
cfg: slotCfg,
|
|
632
932
|
params,
|
|
633
933
|
status: "idle",
|
|
@@ -639,23 +939,23 @@ function createEngine(adapterRegistry) {
|
|
|
639
939
|
prepare(slot);
|
|
640
940
|
bus.emit("slotRegistered", publicSlot(slot));
|
|
641
941
|
const lazy = slotCfg.lazy !== void 0 ? slotCfg.lazy : cfg.lazy.enabled;
|
|
642
|
-
if (lazy && lazyIO) lazyIO.observe(
|
|
942
|
+
if (lazy && lazyIO) lazyIO.observe(el2);
|
|
643
943
|
else enqueue(slot);
|
|
644
944
|
}
|
|
645
945
|
function prepare(slot) {
|
|
646
|
-
const { el, cfg: c } = slot;
|
|
647
|
-
if (c.minHeight)
|
|
648
|
-
|
|
946
|
+
const { el: el2, cfg: c } = slot;
|
|
947
|
+
if (c.minHeight) el2.style.minHeight = c.minHeight + "px";
|
|
948
|
+
el2.classList.add("ual-slot");
|
|
649
949
|
if (c.label) {
|
|
650
950
|
const l = document.createElement("div");
|
|
651
951
|
l.className = "ual-label";
|
|
652
952
|
l.textContent = c.label;
|
|
653
953
|
l.style.cssText = "font:10px/1.4 sans-serif;color:#888;text-align:center;text-transform:uppercase;letter-spacing:.05em";
|
|
654
|
-
|
|
954
|
+
el2.appendChild(l);
|
|
655
955
|
}
|
|
656
956
|
const box = document.createElement("div");
|
|
657
957
|
box.className = "ual-creative";
|
|
658
|
-
|
|
958
|
+
el2.appendChild(box);
|
|
659
959
|
slot.box = box;
|
|
660
960
|
}
|
|
661
961
|
function observeDom() {
|
|
@@ -780,7 +1080,7 @@ function createEngine(adapterRegistry) {
|
|
|
780
1080
|
const res = await withTimeout(adapter.request(req, pcfg, c), timeout, {});
|
|
781
1081
|
group.forEach((s, i) => {
|
|
782
1082
|
const raw = res[req[i].id];
|
|
783
|
-
if (!raw || !raw.html && !raw.src && !raw.url) {
|
|
1083
|
+
if (!raw || !raw.html && !raw.src && !raw.url && !raw.native) {
|
|
784
1084
|
unfilled.push(s);
|
|
785
1085
|
return;
|
|
786
1086
|
}
|
|
@@ -792,14 +1092,18 @@ function createEngine(adapterRegistry) {
|
|
|
792
1092
|
return;
|
|
793
1093
|
}
|
|
794
1094
|
const ad = n.ad;
|
|
795
|
-
const
|
|
1095
|
+
const onClick = () => {
|
|
1096
|
+
fire(ad.trackers.click);
|
|
1097
|
+
bus.emit("slotClick", publicSlot(s));
|
|
1098
|
+
};
|
|
1099
|
+
const handle = ad.type === "native" ? renderNative(s.box, ad, { onClick }) : ad.type === "video" ? renderVideo(s.box, ad, {
|
|
1100
|
+
onClick,
|
|
1101
|
+
onEvent: (name) => bus.emit("videoEvent", { ...publicSlot(s), event: name }),
|
|
1102
|
+
onError: (error) => bus.emit("error", { provider: pid, slot: s.name, error })
|
|
1103
|
+
}) : renderInFrame(s.box, ad, {
|
|
796
1104
|
slotId: s.instanceId,
|
|
797
1105
|
onClick: () => bus.emit("slotClick", publicSlot(s)),
|
|
798
|
-
onClose: () =>
|
|
799
|
-
destroySlot(s);
|
|
800
|
-
s.el.dataset.ualStatus = "closed";
|
|
801
|
-
collapse(s.el, s.cfg, true);
|
|
802
|
-
}
|
|
1106
|
+
onClose: () => closeSlot(s)
|
|
803
1107
|
});
|
|
804
1108
|
s.cleanup.push(handle.destroy);
|
|
805
1109
|
fire(ad.trackers.impression);
|
|
@@ -826,6 +1130,14 @@ function createEngine(adapterRegistry) {
|
|
|
826
1130
|
s.el.style.display = "";
|
|
827
1131
|
bus.emit("slotRendered", { ...publicSlot(s), width: info.width, height: info.height, meta: info.meta });
|
|
828
1132
|
const vcfg = { ...cfg.viewability, ...s.cfg.viewability };
|
|
1133
|
+
if (info.ad && info.ad.type === "video" && !(s.cfg.viewability && s.cfg.viewability.durationMs !== void 0)) {
|
|
1134
|
+
vcfg.durationMs = Math.max(vcfg.durationMs, 2e3);
|
|
1135
|
+
}
|
|
1136
|
+
const pos = stickyPosition(s.cfg.sticky);
|
|
1137
|
+
if (pos && info.height) {
|
|
1138
|
+
if (stickyAllowed(info.height, window.innerHeight)) s.cleanup.push(applySticky(s.el, pos, () => closeSlot(s)));
|
|
1139
|
+
else warn(`Slot "${s.name}": sticky ignorato, l'annuncio (${info.height}px) supera il 30% dello schermo`);
|
|
1140
|
+
}
|
|
829
1141
|
const area = (info.width || 0) * (info.height || 0);
|
|
830
1142
|
if (area > LARGE_AD_AREA && !(s.cfg.viewability && s.cfg.viewability.threshold !== void 0)) {
|
|
831
1143
|
vcfg.threshold = Math.min(vcfg.threshold, 0.3);
|
|
@@ -845,11 +1157,11 @@ function createEngine(adapterRegistry) {
|
|
|
845
1157
|
collapse(s.el, s.cfg);
|
|
846
1158
|
bus.emit("slotEmpty", publicSlot(s));
|
|
847
1159
|
}
|
|
848
|
-
function collapse(
|
|
1160
|
+
function collapse(el2, c, force) {
|
|
849
1161
|
const doCollapse = force || (c.collapseEmpty !== void 0 ? c.collapseEmpty : cfg && cfg.collapseEmpty);
|
|
850
1162
|
if (doCollapse) {
|
|
851
|
-
|
|
852
|
-
|
|
1163
|
+
el2.style.display = "none";
|
|
1164
|
+
el2.style.minHeight = "";
|
|
853
1165
|
}
|
|
854
1166
|
}
|
|
855
1167
|
function clear(s) {
|
|
@@ -897,6 +1209,11 @@ function createEngine(adapterRegistry) {
|
|
|
897
1209
|
s.consentSkipped = false;
|
|
898
1210
|
enqueue(s);
|
|
899
1211
|
}
|
|
1212
|
+
function closeSlot(s) {
|
|
1213
|
+
destroySlot(s);
|
|
1214
|
+
s.el.dataset.ualStatus = "closed";
|
|
1215
|
+
collapse(s.el, s.cfg, true);
|
|
1216
|
+
}
|
|
900
1217
|
function destroySlot(s) {
|
|
901
1218
|
callDestroy(s);
|
|
902
1219
|
clear(s);
|
|
@@ -1261,7 +1578,7 @@ var house_default = {
|
|
|
1261
1578
|
};
|
|
1262
1579
|
|
|
1263
1580
|
// src/lib.js
|
|
1264
|
-
var version = true ? "0.
|
|
1581
|
+
var version = true ? "0.2.0" : "dev";
|
|
1265
1582
|
function createUal() {
|
|
1266
1583
|
const registry = new Map(Object.entries({
|
|
1267
1584
|
json: json_default,
|