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 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 [![NPM version](https://img.shields.io/npm/v/universal-ad-loader)](https://www.npmjs.com/package/universal-ad-loader) [![Downloads](https://img.shields.io/npm/dm/universal-ad-loader)](https://www.npmjs.com/package/universal-ad-loader) [![Node version](https://img.shields.io/node/v/universal-ad-loader)](https://www.npmjs.com/package/universal-ad-loader) [![Bundle size](https://img.shields.io/bundlephobia/minzip/universal-ad-loader)](https://bundlephobia.com/package/universal-ad-loader) [![License](https://img.shields.io/npm/l/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://adcdn.yourdomain.net/ual.min.js"
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://adcdn.yourdomain.net/ual.min.js"></script>
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
- > **What to upload to the CDN:** the loader (`dist/ual.min.js`) and one configuration JSON per site, with the right headers: see `docs/cdn.md`.
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/regression.mjs` | + Chromium |
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: { impression: cleanList(t.impression), viewable: cleanList(t.viewable), click: cleanList(t.click) },
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(el, { threshold = 0.5, durationMs = 1e3 } = {}, onViewable) {
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(el);
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((el) => list.push(el));
887
+ if (root.querySelectorAll) root.querySelectorAll("[data-ad-slot]").forEach((el2) => list.push(el2));
588
888
  list.forEach(maybeRegister);
589
889
  }
590
- function maybeRegister(el) {
591
- const id = el.dataset.ualId;
592
- if (!id) return register(el);
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(el.dataset.ualStatus)) return;
595
- resetEl(el);
596
- register(el);
894
+ if (FINAL_STATUS.has(el2.dataset.ualStatus)) return;
895
+ resetEl(el2);
896
+ register(el2);
597
897
  }
598
- function resetEl(el) {
599
- delete el.dataset.ualId;
600
- delete el.dataset.ualStatus;
601
- el.querySelectorAll(":scope > .ual-label, :scope > .ual-creative").forEach((n) => n.remove());
602
- el.classList.remove("ual-slot", "ual-filled");
603
- el.style.display = "";
604
- el.style.minHeight = "";
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(el) {
607
- const name = el.getAttribute("data-ad-slot");
906
+ function register(el2) {
907
+ const name = el2.getAttribute("data-ad-slot");
608
908
  const slotCfg = cfg.slots[name];
609
- el.dataset.ualId = name + "#" + ++seq2;
909
+ el2.dataset.ualId = name + "#" + ++seq2;
610
910
  if (!slotCfg) {
611
911
  log("Slot non configurato, ignorato:", name);
612
- el.dataset.ualStatus = "unconfigured";
912
+ el2.dataset.ualStatus = "unconfigured";
613
913
  return;
614
914
  }
615
915
  if (slotCfg.enabled === false) {
616
916
  log("Slot disabilitato:", name);
617
- el.dataset.ualStatus = "disabled";
618
- collapse(el, slotCfg);
917
+ el2.dataset.ualStatus = "disabled";
918
+ collapse(el2, slotCfg);
619
919
  return;
620
920
  }
621
921
  let params = {};
622
922
  try {
623
- params = el.dataset.adParams ? JSON.parse(el.dataset.adParams) : {};
923
+ params = el2.dataset.adParams ? JSON.parse(el2.dataset.adParams) : {};
624
924
  } catch (e) {
625
- warn("data-ad-params non \xE8 JSON valido", el);
925
+ warn("data-ad-params non \xE8 JSON valido", el2);
626
926
  }
627
927
  const slot = {
628
- instanceId: el.dataset.ualId,
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(el);
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) el.style.minHeight = c.minHeight + "px";
648
- el.classList.add("ual-slot");
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
- el.appendChild(l);
954
+ el2.appendChild(l);
655
955
  }
656
956
  const box = document.createElement("div");
657
957
  box.className = "ual-creative";
658
- el.appendChild(box);
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 handle = renderInFrame(s.box, ad, {
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(el, c, force) {
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
- el.style.display = "none";
852
- el.style.minHeight = "";
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.1.2" : "dev";
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,