universal-ad-loader 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,24 @@
1
+ # 0.1.1
2
+
3
+ **Changes**
4
+ - Documentation (README, guides, changelog) translated to English.
5
+ - `engines`: Node 22 or later.
6
+ - `browser`, `jsdelivr` and `unpkg` fields point to `dist/ual.min.js`, so the CDN default file is the loader.
7
+ - The internal publishing guide (`docs/pubblicazione-npm.md`) is no longer included in the package.
8
+
9
+ # 0.1.0
10
+
11
+ First release.
12
+
13
+ **What's new**
14
+ - Loader served with a `<script>` tag (`dist/ual.min.js`): slots, centralized JSON configuration,
15
+ lazy load, waterfall across providers, IAB TCF consent, isolated iframe rendering, viewability and events.
16
+ - Adapters: `json`, `iframe`, `revive`, `revive-async`, `gpt`, `adsense`, `house`.
17
+ - Core as a library (ESM and CJS), safe to import on the server: `createUal`, `getUal`, `defineConfig`.
18
+ - Components for React, Next.js, Vue and Astro, which start UAL after hydration and handle page changes.
19
+ - TypeScript types and a JSON Schema of the configuration.
20
+
21
+ **Good to know**
22
+ - `adsense`, `revive-async` and `gpt` are written against the public APIs but have not been tried on real accounts.
23
+ - Nuxt, Remix and React Router have not been tried.
24
+ - With a strict CSP banners show up, but `UALExt` and the tracked click do not work without `'unsafe-inline'`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArdensLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,208 @@
1
- # Temporary Holding Version
1
+ # UAL — Universal Ad Loader
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ ## Installing on a site
6
+
7
+ ```html
8
+ <script async src="https://adcdn.yourdomain.net/ual.min.js"
9
+ data-config="https://adcdn.yourdomain.net/config/site_123.json"></script>
10
+
11
+ <div data-ad-slot="top"></div>
12
+ <div data-ad-slot="sidebar" data-ad-params='{"zone":7}'></div>
13
+ ```
14
+
15
+ - `data-ad-slot` is the slot name in the configuration. The same name can appear more than once (e.g. in-feed).
16
+ - `data-ad-params` (optional) overrides the provider's `params` for that instance.
17
+ - Divs added later (SPA, infinite scroll) are detected automatically. On a route change, call `ual.pageView()`.
18
+
19
+ > **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`.
20
+
21
+ ## Configuration (JSON served from the CDN)
22
+
23
+ See `examples/site_demo.json`. Main fields:
24
+
25
+ | Field | Description |
26
+ |---|---|
27
+ | `site` | Site ID, sent to the adapters |
28
+ | `consent` | `{ mode:'tcf'\|'none', timeoutMs, requiredPurposes:[1], vendorId }` |
29
+ | `lazy` | `{ enabled, fetchMargin:'200px' }`. The slot is requested when it comes within that distance of the viewport |
30
+ | `viewability` | `{ threshold:0.5, durationMs:1000 }` (MRC standard) |
31
+ | `collapseEmpty` | hides the slot if no provider fills it |
32
+ | `providerTimeoutMs` | default timeout per provider |
33
+ | `targeting` | page-level key-values sent to all adapters |
34
+ | `providers` | `{ id: { adapter:'json'\|'iframe'\|'revive'\|'revive-async'\|'gpt'\|'adsense'\|'house', consent:'optional'\|'required'\|'contextual', timeoutMs, ...adapter options } }` |
35
+ | `slots` | `{ name: { sizes \| sizeMapping, providers:[...waterfall], label, minHeight, lazy, collapseEmpty, refreshSec, maxRefresh, enabled } }` |
36
+ | `pageRules` | `[{ match:'^/on-air', disable:[...], enable:[...], targeting:{}, slots:{ override } }]` |
37
+
38
+ **Waterfall**: a slot's `providers` is an ordered list (`"house"` or `{ "id":"main", "params":{...} }`). If the first one does not fill the slot, the next one is tried. At each level, the slots that become ready together are grouped into **a single request per provider**.
39
+
40
+ **Consent per provider** (`providers.X.consent`):
41
+
42
+ | Value | Behavior |
43
+ |---|---|
44
+ | `optional` (default) | Waits for the CMP response (up to `consent.timeoutMs`) and receives the consent as it is |
45
+ | `required` | Like `optional`, but if consent is missing the provider is **skipped** and the next level is tried (e.g. a contextual house fallback) |
46
+ | `contextual` | **Does not wait for the CMP**: starts immediately and receives the consent known at that moment (often `granted:false`). For contextual requests with no cookies or IDs |
47
+
48
+ Consent is followed for the whole life of the page: if the CMP shows up late (up to `consent.lateWatchMs`, default 30 s) or the user changes their choice, UAL emits the `consent` event and, when it switches to `granted:true`, reloads the slots that had skipped a `required` provider.
49
+
50
+ ## Included adapters
51
+
52
+ | Adapter | Use | Detects empty? |
53
+ |---|---|---|
54
+ | `json` | Your ad server: batch POST to `endpoint` (contract below) | Yes |
55
+ | `iframe` | Any URL that returns the banner (`work.php?n=..&size=..`) with `{params.x}`, `{width}`, `{cb}`… templates | No (treated as filled) |
56
+ | `revive` | Revive Adserver via `afr.php` (iframe) | No |
57
+ | `revive-async` | Revive via `asyncjs.php` + `<ins>` | Yes (observes the DOM) |
58
+ | `gpt` | Google Ad Manager, SRA with `disableInitialLoad` + `refresh` | Yes (`slotRenderEnded`) |
59
+ | `adsense` | AdSense as backfill | Yes (`data-ad-status`) |
60
+ | `house` | Creatives in the config, weighted pick by slot/device/date | Yes |
61
+
62
+ ### The `json` adapter contract
63
+
64
+ Request `POST {endpoint}`:
65
+ ```json
66
+ {
67
+ "site": "site_123",
68
+ "page": { "url": "...", "path": "/", "ref": "...", "title": "..." },
69
+ "device": "desktop", "viewport": { "w": 1280, "h": 900 },
70
+ "targeting": { "section": "onair" },
71
+ "consent": { "gdprApplies": true, "tcString": "...", "granted": true },
72
+ "slots": [ { "id": "top#1", "name": "top", "sizes": [[728,90]], "params": { "zone": 1 } } ]
73
+ }
74
+ ```
75
+ Response (200, or 204 if empty):
76
+ ```json
77
+ { "ads": [ {
78
+ "slotId": "top#1", "type": "image", "src": "https://cdn/.../b.webp",
79
+ "width": 728, "height": 90, "clickUrl": "https://ads/c?t=SIGNED",
80
+ "trackers": { "impression": ["https://ads/i?t=..."], "viewable": ["https://ads/v?t=..."], "click": [] }
81
+ } ] }
82
+ ```
83
+ `type`: `image` (src + clickUrl), `html` (markup, `clickTag` available), `url` (external iframe). Slots with no entry fall through to the next provider.
84
+
85
+ ## Rendering and creative API
86
+
87
+ `image`/`html` creatives run in a `srcdoc` iframe with a sandbox **without** `allow-same-origin` (opaque origin): they cannot read the page, its cookies or its DOM. Inside the iframe the following are available:
88
+
89
+ ```js
90
+ clickTag // = clickUrl
91
+ UALExt.click() // tracks the click and opens clickTag
92
+ UALExt.expand(728, 300) // resizes (max 2000px)
93
+ UALExt.collapse() // back to the original size
94
+ UALExt.close() // closes and hides the slot
95
+ ```
96
+ Messages are accepted only from the slot's own iframe (checked on `event.source`).
97
+
98
+ **Creative validation.** Every ad received from an adapter goes through a check before rendering; if it is not valid it is discarded (log + `error` event) and the next provider is tried:
99
+
100
+ - `width` and `height` are required, integers between 1 and 2000.
101
+ - `src` (image), `url` (url), `clickUrl` and all `trackers`: only `http:`/`https:` (and `data:image/` for `src`). No `javascript:`.
102
+ - 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.
103
+
104
+ ## JavaScript API
105
+
106
+ ```js
107
+ window.ual = window.ual || { cmd: [] };
108
+ ual.cmd.push(function (u) {
109
+ u.on('slotRendered', (e) => console.log(e.slot, e.provider, e.width, e.height));
110
+ u.on('slotEmpty', (e) => {});
111
+ u.on('slotViewable', (e) => {});
112
+ u.on('slotClick', (e) => {});
113
+ u.on('error', (e) => {});
114
+ u.setTargeting({ author: 'mario' });
115
+ });
116
+ // Without data-config: ual.cmd.push(u => u.init({ ...config })) or u.init('https://.../site.json')
117
+ ual.refresh(['top']); // reloads slots (all of them if called without arguments)
118
+ ual.pageView(); // SPA: new page
119
+ ual.getSlots(); ual.getConsent();
120
+ ual.isInitialized(); // true if init has already been started (even if not finished yet)
121
+ ual.whenReady(); // the init promise, or null if never started
122
+ ```
123
+ `setTargeting` also works before init (inside `ual.cmd.push`), stays valid after `pageView()`, and a `null` value removes the key. The `pageRules` targeting is recomputed on every page. The `gpt` adapter reapplies it on every request and removes keys that are no longer present.
124
+
125
+ The script attribute can be named `data-config` or `data-ual-config` (equivalent; the second is also read when the script was inserted dynamically by a framework).
126
+
127
+ Debug: `?ual_debug=1` in the URL, or `localStorage.ual_debug = '1'`.
128
+
129
+ ## With Astro, Next.js, React, Vue and Node
130
+
131
+ UAL is a browser script (it does not run in Node). For sites with a build step there is also the **package**: server-safe import, components for React, Next.js, Vue and Astro, TypeScript types (`defineConfig`). See `docs/libreria.md`. **The `universal-ad-loader` package is published on npm** (`npm i universal-ad-loader`).
132
+
133
+ ```tsx
134
+ import { UalProvider, AdSlot } from 'universal-ad-loader/next'; // or /react, /vue; for Astro use the files in astro/
135
+
136
+ <UalProvider config={config}> {/* starts after hydration, handles page changes */}
137
+ <AdSlot name="top" minHeight={90} />
138
+ </UalProvider>
139
+ ```
140
+
141
+ If you use the tag with a framework instead, things verified with a server-rendered React app:
142
+
143
+ - **Load the loader after hydration** (`next/script` with `strategy="afterInteractive"`, or a `useEffect`). If it starts earlier, UAL inserts the banner into the `div` before React attaches: *Hydration failed* error (even with `suppressHydrationWarning`) and the banner is requested twice. The components avoid this on their own.
144
+ - **Client-side page change:** new slots register themselves, but `pageRules` and targeting are only recomputed with `ual.pageView()`.
145
+ - **Do not import `dist/ual.js` in server code:** it uses `window` at load time (`ReferenceError` in Node). The package core (`dist/index.*`) is safe, though.
146
+ - **Strict CSP:** creatives inherit the page's CSP. Tested: without `'unsafe-inline'` for scripts and styles the banner shows up but `UALExt` and the tracked click do not work; the same goes for nonces with `'strict-dynamic'`.
147
+
148
+ Guide with examples and a CSP table: configurator, `/docs/` page.
149
+
150
+ ## Other documents
151
+
152
+ - `docs/esempio.md`: complete client site (config, flow, minimal `decide` endpoint).
153
+ - `docs/cdn.md`: **which files to put on the CDN** (loader and configuration), headers, structure, what is not needed and how to update.
154
+ - `docs/libreria.md`: UAL as a package (React/Next/Vue/Astro components, types, server-safe core, how it is verified).
155
+ - `docs/ad-server-esistenti.md`: how to connect AdManager, Banner Exchange Pro, Banner Exchange and the legacy PHP systems with the `iframe` adapter, with limits and checks.
156
+ - `schema/ual-config.schema.json`: JSON Schema of the configuration.
157
+ - The web configurator (`../ual-configurator`) has an HTML guide with examples that open in the editor.
158
+
159
+ ## Custom adapters
160
+
161
+ ```js
162
+ ual.cmd.push(u => u.registerAdapter('myserver', {
163
+ init(providerCfg, ctx) {}, // optional, once per provider
164
+ // A) the adapter returns the ads and UAL renders them in the sandbox:
165
+ async request(slots, providerCfg, ctx) { // slots: [{id,name,sizes,params,el}]
166
+ return { [slots[0].id]: { type:'image', src:'...', width:300, height:250, clickUrl:'...' } };
167
+ },
168
+ // B) or the adapter renders by itself inside slot.el:
169
+ // async fill(slots, providerCfg, ctx) { return { [id]: { filled:true, width, height } } },
170
+ destroy(slot, providerCfg) {}, // optional
171
+ }));
172
+ ```
173
+ `ctx` contains `site, page, device, viewport, targeting, consent`. To register a custom adapter before the automatic init, put `ual.cmd.push(...)` **before** the loader tag.
174
+
175
+ ## Build and tests
176
+
177
+ ```bash
178
+ npm ci # base dependencies (~120 MB)
179
+ npm ci --prefix test/heavy # only for the framework tests: Next.js and Astro (~620 MB)
180
+ npx playwright install chromium # once, for the browser tests
181
+ npm test # build + all suites, about 2 minutes
182
+ ```
183
+
184
+ To work on one part at a time (this is what CI does, in parallel jobs):
185
+
186
+ | Command | What it runs | Needs |
187
+ |---|---|---|
188
+ | `npm run lint` | oxlint on the sources | base dependencies only |
189
+ | `npm run test:node` | `test/package.mjs` | base dependencies only (no browser) |
190
+ | `npm run test:browser` | `test/run.mjs` and `test/regression.mjs` | + Chromium |
191
+ | `npm run test:frameworks` | `test/wrappers.mjs` | + `test/heavy` and Chromium |
192
+
193
+ Every suite also writes a JUnit report to `test-results/` (CI shows it in the Tests tab).
194
+
195
+ | Suite | What it verifies |
196
+ |---|---|
197
+ | `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 |
198
+ | `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 |
199
+ | `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) |
200
+ | `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 |
201
+
202
+ The `adsense` and `revive-async` adapters are written against the public APIs but are not tested: they need to be tried on your own accounts and servers. `gpt` too must be tried with a real Google Ad Manager: the stub verifies UAL's logic, not GPT's behavior. Nuxt, Remix and React Router have not been run.
203
+
204
+ ## Notes
205
+
206
+ - **Consent**: on client sites your ad server is a third-party vendor. Without consent, send only contextual requests (no cookies or IDs). `vendorId` is needed if your server is registered in the IAB GVL. Have the privacy setup reviewed by a lawyer.
207
+ - **Automatic refresh** (`refreshSec`): only if the slot is on screen and the tab is visible. GPT and AdSense have their own refresh policies.
208
+ - **CLS**: use `minHeight` on above-the-fold slots.
@@ -0,0 +1,23 @@
1
+ ---
2
+ // Una posizione pubblicitaria: un div vuoto che UAL riempie.
3
+ // <AdSlot name="top" minHeight={90} params={{ zona: 7 }} />
4
+ interface Props {
5
+ /** Nome dello slot in configurazione (data-ad-slot). */
6
+ name: string;
7
+ /** Sovrascrive i `params` del provider per questa istanza. */
8
+ params?: Record<string, unknown>;
9
+ /** Riserva lo spazio già nell'HTML (evita salti di layout). */
10
+ minHeight?: number;
11
+ class?: string;
12
+ id?: string;
13
+ }
14
+ const { name, params, minHeight, class: className, id } = Astro.props;
15
+ ---
16
+
17
+ <div
18
+ data-ad-slot={name}
19
+ data-ad-params={params ? JSON.stringify(params) : undefined}
20
+ id={id}
21
+ class={className}
22
+ style={minHeight ? `min-height:${minHeight}px` : undefined}
23
+ ></div>
@@ -0,0 +1,34 @@
1
+ ---
2
+ // Carica UAL incluso nel bundle del sito (nessuna CDN per il loader), con la configurazione nella pagina.
3
+ // <UalBundled config={{ site: '…', providers: {…}, slots: {…} }} />
4
+ // <UalBundled config="/config/sito.json" />
5
+ // Per caricare il loader da una CDN usa invece UalLoader.
6
+ // La configurazione sta in un blocco JSON (non eseguito, quindi compatibile con una CSP rigida).
7
+ import type { UalConfig } from '../dist/index';
8
+
9
+ interface Props {
10
+ /** La configurazione, oppure l'URL del JSON. */
11
+ config: UalConfig | string;
12
+ }
13
+ const { config } = Astro.props;
14
+ const data = JSON.stringify(config).replace(/</g, '\\u003c');
15
+ ---
16
+
17
+ <script type="application/json" data-ual-config set:html={data}></script>
18
+ <script>
19
+ import { getUal } from '../dist/index.mjs';
20
+
21
+ const el = document.querySelector('script[data-ual-config]');
22
+ if (el && el.textContent) {
23
+ const ual = getUal();
24
+ if (!ual.isInitialized()) {
25
+ ual.init(JSON.parse(el.textContent)).catch((e) => console.warn('[UAL]', e && e.message ? e.message : e));
26
+ }
27
+ // View Transitions: il primo astro:page-load è il caricamento iniziale, gli altri sono navigazioni
28
+ let first = true;
29
+ document.addEventListener('astro:page-load', () => {
30
+ if (first) { first = false; return; }
31
+ ual.pageView();
32
+ });
33
+ }
34
+ </script>
@@ -0,0 +1,40 @@
1
+ ---
2
+ // Carica UAL in una pagina Astro con il loader servito da una CDN (aggiornabile senza rideploy del sito).
3
+ // <UalLoader src="https://cdn.tuodominio.net/ual.min.js" config="https://cdn.tuodominio.net/config/sito.json" />
4
+ // <UalLoader src="…/ual.min.js" config={{ site: '…', providers: {…}, slots: {…} }} />
5
+ // Per includere UAL nel bundle del sito usa invece UalBundled.
6
+ // Con le View Transitions (ClientRouter) ricalcola le regole per pagina a ogni navigazione.
7
+ import type { UalConfig } from '../dist/index';
8
+
9
+ interface Props {
10
+ /** URL del loader (ual.min.js). */
11
+ src: string;
12
+ /** URL del JSON di configurazione, oppure la configurazione stessa. */
13
+ config: UalConfig | string;
14
+ }
15
+ const { src, config } = Astro.props;
16
+ const isUrl = typeof config === 'string';
17
+ // "<" escapato: i dati non possono chiudere lo script
18
+ const data = JSON.stringify(config).replace(/</g, '\\u003c');
19
+ const init = `window.ual=window.ual||{cmd:[]};window.ual.cmd.push(function(u){u.init(${data})});`;
20
+ ---
21
+
22
+ {isUrl
23
+ ? <script is:inline async src={src} data-ual-config={config as string}></script>
24
+ : (
25
+ <Fragment>
26
+ <script is:inline set:html={init}></script>
27
+ <script is:inline async src={src}></script>
28
+ </Fragment>
29
+ )}
30
+ <script is:inline>
31
+ (function () {
32
+ // Il primo astro:page-load coincide con il caricamento iniziale: lì UAL parte da solo
33
+ var first = true;
34
+ document.addEventListener('astro:page-load', function () {
35
+ if (first) { first = false; return; }
36
+ var ual = window.ual;
37
+ if (ual && ual.cmd && typeof ual.cmd.push === 'function') ual.cmd.push(function (u) { u.pageView(); });
38
+ });
39
+ })();
40
+ </script>
@@ -0,0 +1,13 @@
1
+ // Tipi opzionali per chi usa il tag <script> (window.ual): import 'universal-ad-loader/global';
2
+ // Sono in un file a parte perché dichiarano una proprietà globale di Window, che potrebbe
3
+ // confliggere con una dichiarazione già presente nel progetto.
4
+ import type { Ual } from './index';
5
+
6
+ declare global {
7
+ interface Window {
8
+ /** Prima del caricamento del loader è solo la coda dei comandi; dopo è l'API completa. */
9
+ ual?: (Partial<Ual> & { cmd: { push(fn: (u: Ual) => void): void } | Array<(u: Ual) => void> });
10
+ }
11
+ }
12
+
13
+ export {};