@adxensor/publisher-sdk 1.0.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.
@@ -0,0 +1,12 @@
1
+ import type { AdXensorApi } from './api.js';
2
+ export declare class Tracker {
3
+ private readonly api;
4
+ private readonly siteId;
5
+ private readonly sessionId;
6
+ constructor(api: AdXensorApi, siteId: string, sessionId: string);
7
+ private base;
8
+ pageview(): void;
9
+ impression(adId: string, slotId: string, impressionToken: string): void;
10
+ click(adId: string, slotId: string, clickToken: string): void;
11
+ view(adId: string, slotId: string): void;
12
+ }
@@ -0,0 +1,87 @@
1
+ export type AdFormat = '728x90' | '970x90' | '970x250' | '300x250' | '300x600' | '160x600' | '320x50' | '320x100' | '468x60' | 'auto' | (string & {});
2
+ /** @deprecated Use AdFormat */
3
+ export type AdSize = AdFormat;
4
+ export type DeviceType = 'mobile' | 'tablet' | 'desktop';
5
+ /** State machine written on the <ins> element via data-adx-state */
6
+ export type SlotState = 'loading' | 'filled' | 'empty' | 'error';
7
+ export interface AdXensorConfig {
8
+ /** Publisher site ID — required */
9
+ siteId: string;
10
+ /** Optional API key for authenticated publishers */
11
+ apiKey?: string;
12
+ /** Override the default API base URL */
13
+ apiUrl?: string;
14
+ /** Log debug info to console (default: false) */
15
+ debug?: boolean;
16
+ /** Lazy-load ads when they approach the viewport (default: true) */
17
+ lazyLoad?: boolean;
18
+ }
19
+ export interface SlotOptions {
20
+ /** Named slot identifier (overrides data-ad-slot attribute) */
21
+ slotId?: string;
22
+ /** Ad format / size (overrides data-ad-format attribute) */
23
+ format?: AdFormat;
24
+ /** Override lazy loading for this slot only */
25
+ lazy?: boolean;
26
+ }
27
+ export interface ServeRequest {
28
+ siteId: string;
29
+ slotId: string;
30
+ size: string;
31
+ device: DeviceType;
32
+ url: string;
33
+ referrer: string;
34
+ /**
35
+ * BCP-47 tag from navigator.language, normalised to 2-char ISO 639-1.
36
+ * e.g. "fr", "en", "es" — used for language targeting by ads_core.
37
+ */
38
+ language: string;
39
+ screenWidth: number;
40
+ sessionId: string;
41
+ }
42
+ export interface AdResponse {
43
+ adId: string;
44
+ campaignId: string;
45
+ type: 'image' | 'html';
46
+ imageUrl?: string;
47
+ htmlContent?: string;
48
+ /**
49
+ * Full click URL returned by ads_core (e.g. https://ads.adxensor.com/v1/click/TOKEN).
50
+ * Use this directly as the <a> href — ads_core handles the redirect internally.
51
+ */
52
+ clickHref: string;
53
+ width: number;
54
+ height: number;
55
+ altText: string;
56
+ /** Event ID used for POST /v1/events/impression */
57
+ impressionToken: string;
58
+ /** Event ID used for POST /v1/events/click */
59
+ clickToken: string;
60
+ }
61
+ /**
62
+ * Base fields sent with EVERY event (impression, click, view, pageview).
63
+ * Country is resolved server-side from IP (geoip-lite) and NOT sent by the SDK.
64
+ */
65
+ interface BaseEventPayload {
66
+ siteId: string;
67
+ sessionId: string;
68
+ url: string;
69
+ device: DeviceType;
70
+ /**
71
+ * Normalised 2-char ISO 639-1 language code from navigator.language.
72
+ * e.g. "fr", "en", "es", "pt"
73
+ */
74
+ language: string;
75
+ screenWidth: number;
76
+ ts: number;
77
+ }
78
+ export interface PageviewPayload extends BaseEventPayload {
79
+ referrer: string;
80
+ }
81
+ export interface AdEventPayload extends BaseEventPayload {
82
+ adId: string;
83
+ slotId: string;
84
+ impressionToken?: string;
85
+ clickToken?: string;
86
+ }
87
+ export {};
@@ -0,0 +1,32 @@
1
+ /**
2
+ * CDN entry point — loaded via <script> tag (IIFE bundle).
3
+ *
4
+ * ── Minimal snippet (auto-fill all <ins class="adxensor"> on load) ───────────
5
+ *
6
+ * <script async
7
+ * src="https://cdn.adxensor.com/tag.js"
8
+ * data-ad-client="pub-XXXXXXXX">
9
+ * </script>
10
+ *
11
+ * <ins class="adxensor"
12
+ * style="display:block"
13
+ * data-ad-slot="header-banner"
14
+ * data-ad-format="728x90">
15
+ * </ins>
16
+ *
17
+ * ── AdSense-style push (fill one slot at a time) ─────────────────────────────
18
+ *
19
+ * <ins class="adxensor" style="display:block"
20
+ * data-ad-slot="sidebar" data-ad-format="300x250"></ins>
21
+ * <script>
22
+ * (window.adxensor = window.adxensor || []).push({});
23
+ * </script>
24
+ *
25
+ * ── Pre-load queue (before script is ready) ──────────────────────────────────
26
+ *
27
+ * <script>
28
+ * window.adxensor = window.adxensor || [];
29
+ * window.adxensor.push({});
30
+ * </script>
31
+ */
32
+ export {};
@@ -0,0 +1,9 @@
1
+ export { AdXensor } from './core/AdXensor.js';
2
+ export { AdXensorApi } from './core/api.js';
3
+ export { Tracker } from './core/tracker.js';
4
+ export { AdSlot } from './core/AdSlot.js';
5
+ export { renderAd } from './core/renderer.js';
6
+ export { getDevice, resolveSize } from './core/device.js';
7
+ export { getSessionId, hasFired, markFired } from './core/session.js';
8
+ export type { AdXensorConfig, SlotOptions, SlotState, AdFormat, AdSize, // deprecated alias for AdFormat — kept for backwards compat
9
+ DeviceType, AdResponse, ServeRequest, AdEventPayload, PageviewPayload, } from './core/types.js';
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@adxensor/publisher-sdk",
3
+ "version": "1.0.0",
4
+ "description": "AdXensor publisher SDK — CDN tag and npm package for ad serving, impression, click, and view tracking",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./dist/npm/cjs/index.cjs",
9
+ "module": "./dist/npm/esm/index.js",
10
+ "types": "./dist/npm/types/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/npm/esm/index.js",
14
+ "require": "./dist/npm/cjs/index.cjs",
15
+ "types": "./dist/npm/types/index.d.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist/",
20
+ "src/"
21
+ ],
22
+ "scripts": {
23
+ "build": "npm run build:cdn && npm run build:npm",
24
+ "build:cdn": "node scripts/build-cdn.mjs",
25
+ "build:npm": "node scripts/build-npm.mjs",
26
+ "typecheck": "tsc --noEmit",
27
+ "clean": "rm -rf dist"
28
+ },
29
+ "devDependencies": {
30
+ "esbuild": "^0.21.0",
31
+ "typescript": "^5.4.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }
@@ -0,0 +1,169 @@
1
+ import type { AdXensorApi } from './api.js';
2
+ import type { Tracker } from './tracker.js';
3
+ import type { SlotOptions } from './types.js';
4
+ import { getDevice, resolveSize } from './device.js';
5
+ import { renderAd } from './renderer.js';
6
+
7
+ const VIEW_THRESHOLD = 0.5; // 50% visible
8
+ const VIEW_DURATION = 1000; // 1 second held in view = fires view event
9
+
10
+ export class AdSlot {
11
+ private readonly slotId: string;
12
+ private observer: IntersectionObserver | null = null;
13
+ private viewTimer: ReturnType<typeof setTimeout> | null = null;
14
+
15
+ constructor(
16
+ private readonly el: HTMLElement,
17
+ private readonly api: AdXensorApi,
18
+ private readonly tracker: Tracker,
19
+ private readonly siteId: string,
20
+ private readonly sessionId: string,
21
+ private readonly options: SlotOptions = {},
22
+ ) {
23
+ // AdSense-style: data-ad-slot. Falls back to element id or a random id.
24
+ this.slotId =
25
+ options.slotId ??
26
+ el.dataset.adSlot ?? // data-ad-slot="banner-top"
27
+ (el.id || `adx-${Math.random().toString(36).slice(2, 9)}`);
28
+ }
29
+
30
+ // ─── Load entry point ─────────────────────────────────────────────────────
31
+
32
+ load(lazy = true): void {
33
+ if (this.el.dataset.adxState) return; // idempotent — already in-flight or done
34
+
35
+ if (lazy && typeof IntersectionObserver !== 'undefined') {
36
+ this.watchForEntry();
37
+ } else {
38
+ void this.fetch();
39
+ }
40
+ }
41
+
42
+ // ─── Lazy entry: wait until element is near the viewport ─────────────────
43
+
44
+ private watchForEntry(): void {
45
+ const io = new IntersectionObserver(
46
+ (entries) => {
47
+ if (entries[0]?.isIntersecting) {
48
+ io.disconnect();
49
+ void this.fetch();
50
+ }
51
+ },
52
+ { rootMargin: '200px', threshold: 0 },
53
+ );
54
+ io.observe(this.el);
55
+ }
56
+
57
+ // ─── Fetch ad from API ────────────────────────────────────────────────────
58
+
59
+ private async fetch(): Promise<void> {
60
+ if (this.el.dataset.adxState) return; // double-check after async gap
61
+ this.setState('loading');
62
+
63
+ try {
64
+ // AdSense-style: data-ad-format. Falls back to options.format then 'auto'.
65
+ const size = resolveSize(
66
+ this.options.format ?? this.el.dataset.adFormat ?? 'auto',
67
+ this.el,
68
+ );
69
+ const [w, h] = size.split('x').map(Number);
70
+
71
+ // Reserve exact dimensions to prevent layout shift (CLS)
72
+ if (w && h) {
73
+ this.el.style.width = `${w}px`;
74
+ this.el.style.maxWidth = '100%';
75
+ this.el.style.minHeight = `${h}px`;
76
+ }
77
+
78
+ const ad = await this.api.serveAd({
79
+ siteId: this.siteId,
80
+ slotId: this.slotId,
81
+ size,
82
+ device: getDevice(),
83
+ url: location.href,
84
+ referrer: document.referrer,
85
+ // Normalise "fr-FR" → "fr"; server resolves country from IP separately
86
+ language: (navigator.language ?? 'fr').split('-')[0].toLowerCase(),
87
+ screenWidth: screen.width,
88
+ sessionId: this.sessionId,
89
+ });
90
+
91
+ if (!ad) {
92
+ this.setState('empty');
93
+ this.el.style.display = 'none';
94
+ return;
95
+ }
96
+
97
+ // ads_core provides the full click URL directly (handles redirect internally)
98
+ renderAd(this.el, ad, ad.clickHref);
99
+ this.setState('filled');
100
+
101
+ // Click tracking — passive: true because we never call preventDefault
102
+ this.el.addEventListener('click', (e) => {
103
+ if ((e.target as HTMLElement).closest('[data-adx-click]')) {
104
+ this.tracker.click(ad.adId, this.slotId, ad.clickToken);
105
+ }
106
+ }, { passive: true });
107
+
108
+ // Impression fires immediately after render (once per adId × session)
109
+ this.tracker.impression(ad.adId, this.slotId, ad.impressionToken);
110
+
111
+ // View fires after ≥50% visible for ≥1s (IAB standard)
112
+ this.watchVisibility(ad.adId, this.slotId);
113
+
114
+ } catch {
115
+ this.setState('error');
116
+ this.el.style.display = 'none';
117
+ }
118
+ }
119
+
120
+ // ─── Viewability (IAB MRC standard) ──────────────────────────────────────
121
+
122
+ private watchVisibility(adId: string, slotId: string): void {
123
+ if (typeof IntersectionObserver === 'undefined') {
124
+ this.tracker.view(adId, slotId); // fallback: fire immediately
125
+ return;
126
+ }
127
+
128
+ this.observer = new IntersectionObserver(
129
+ ([entry]) => {
130
+ if (!entry) return;
131
+
132
+ if (entry.intersectionRatio >= VIEW_THRESHOLD) {
133
+ if (this.viewTimer === null) {
134
+ this.viewTimer = setTimeout(() => {
135
+ this.tracker.view(adId, slotId);
136
+ this.disconnect(); // stop observing once view is counted
137
+ }, VIEW_DURATION);
138
+ }
139
+ } else {
140
+ // Left viewport before 1s — reset timer
141
+ if (this.viewTimer !== null) {
142
+ clearTimeout(this.viewTimer);
143
+ this.viewTimer = null;
144
+ }
145
+ }
146
+ },
147
+ { threshold: [VIEW_THRESHOLD] },
148
+ );
149
+
150
+ this.observer.observe(this.el);
151
+ }
152
+
153
+ // ─── State machine ────────────────────────────────────────────────────────
154
+
155
+ private setState(state: 'loading' | 'filled' | 'empty' | 'error'): void {
156
+ this.el.dataset.adxState = state;
157
+ }
158
+
159
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
160
+
161
+ disconnect(): void {
162
+ this.observer?.disconnect();
163
+ this.observer = null;
164
+ if (this.viewTimer !== null) {
165
+ clearTimeout(this.viewTimer);
166
+ this.viewTimer = null;
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,136 @@
1
+ import { AdXensorApi } from './api.js';
2
+ import { Tracker } from './tracker.js';
3
+ import { AdSlot } from './AdSlot.js';
4
+ import { getSessionId } from './session.js';
5
+ import type { AdXensorConfig, SlotOptions } from './types.js';
6
+
7
+ /** ads_core public base URL — must include /v1 suffix */
8
+ const DEFAULT_API_URL = 'https://core.adxensor.com/v1';
9
+
10
+ /** Selector for unfilled <ins class="adxensor"> slots */
11
+ const INS_SELECTOR = 'ins.adxensor:not([data-adx-state])';
12
+
13
+ export class AdXensor {
14
+ /** One global instance per page (like AdSense). */
15
+ private static instance: AdXensor | null = null;
16
+
17
+ private readonly api: AdXensorApi;
18
+ private readonly tracker: Tracker;
19
+ private readonly sessionId: string;
20
+ private readonly config: AdXensorConfig;
21
+ private initialized = false;
22
+ private domObserver: MutationObserver | null = null;
23
+
24
+ constructor(config: AdXensorConfig) {
25
+ this.config = config;
26
+ this.sessionId = getSessionId();
27
+ this.api = new AdXensorApi(
28
+ config.apiUrl ?? DEFAULT_API_URL,
29
+ config.apiKey,
30
+ );
31
+ this.tracker = new Tracker(this.api, config.siteId, this.sessionId);
32
+ }
33
+
34
+ // ─── Singleton helpers ────────────────────────────────────────────────────
35
+
36
+ static getInstance(config: AdXensorConfig): AdXensor {
37
+ if (!AdXensor.instance) AdXensor.instance = new AdXensor(config);
38
+ return AdXensor.instance;
39
+ }
40
+
41
+ static reset(): void {
42
+ AdXensor.instance?.destroy();
43
+ AdXensor.instance = null;
44
+ }
45
+
46
+ // ─── Public API ───────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Idempotent init — safe to call multiple times.
50
+ * Fires pageview, fills existing slots, watches DOM for new ones.
51
+ */
52
+ init(): this {
53
+ if (this.initialized) return this;
54
+ this.initialized = true;
55
+ this.tracker.pageview();
56
+ this.fillAll();
57
+ this.watchDom();
58
+ return this;
59
+ }
60
+
61
+ /** Fill every unfilled <ins class="adxensor"> currently in the DOM. */
62
+ fillAll(): void {
63
+ document.querySelectorAll<HTMLElement>(INS_SELECTOR).forEach((el) => this.fill(el));
64
+ }
65
+
66
+ /**
67
+ * AdSense-style push — fills the next unfilled slot in DOM order.
68
+ * Use alongside <script>(window.adxensor = window.adxensor || []).push({})</script>
69
+ */
70
+ push(options: SlotOptions = {}): void {
71
+ const el = document.querySelector<HTMLElement>(INS_SELECTOR);
72
+ if (el) this.fill(el, options);
73
+ }
74
+
75
+ /**
76
+ * Fill a specific element.
77
+ * No-op if the element is already loading/filled/empty/error (idempotent).
78
+ */
79
+ fill(el: HTMLElement, options: SlotOptions = {}): void {
80
+ if (el.dataset.adxState) return; // state machine guard
81
+
82
+ // Enforce display:block — required like AdSense
83
+ if (el.style.display === 'none') el.style.display = 'block';
84
+
85
+ const slot = new AdSlot(
86
+ el,
87
+ this.api,
88
+ this.tracker,
89
+ this.config.siteId,
90
+ this.sessionId,
91
+ options,
92
+ );
93
+
94
+ const lazy = options.lazy ?? this.config.lazyLoad ?? true;
95
+ slot.load(lazy);
96
+ }
97
+
98
+ /**
99
+ * Programmatic slot fill by CSS selector.
100
+ * Logs a warning in debug mode if the element is not found.
101
+ */
102
+ defineSlot(selector: string, options: SlotOptions = {}): void {
103
+ const el = document.querySelector<HTMLElement>(selector);
104
+ if (!el) {
105
+ if (this.config.debug) console.warn(`[AdXensor] defineSlot: "${selector}" not found`);
106
+ return;
107
+ }
108
+ this.fill(el, options);
109
+ }
110
+
111
+ // ─── DOM watcher (SPA support) ────────────────────────────────────────────
112
+
113
+ private watchDom(): void {
114
+ if (typeof MutationObserver === 'undefined') return;
115
+
116
+ this.domObserver = new MutationObserver((mutations) => {
117
+ for (const { addedNodes } of mutations) {
118
+ addedNodes.forEach((node) => {
119
+ if (!(node instanceof HTMLElement)) return;
120
+ if (node.matches('ins.adxensor') && !node.dataset.adxState) {
121
+ this.fill(node);
122
+ }
123
+ node.querySelectorAll<HTMLElement>(INS_SELECTOR).forEach((el) => this.fill(el));
124
+ });
125
+ }
126
+ });
127
+
128
+ this.domObserver.observe(document.body, { childList: true, subtree: true });
129
+ }
130
+
131
+ destroy(): void {
132
+ this.domObserver?.disconnect();
133
+ this.domObserver = null;
134
+ this.initialized = false;
135
+ }
136
+ }
@@ -0,0 +1,80 @@
1
+ import type { ServeRequest, AdResponse, AdEventPayload, PageviewPayload } from './types.js';
2
+
3
+ /** ads_core /v1/ad response shape */
4
+ interface AdsCoreAdResponse {
5
+ creative_url: string;
6
+ click_url: string; // full URL e.g. https://ads.adxensor.com/v1/click/TOKEN
7
+ impression_url: string; // full URL e.g. https://ads.adxensor.com/v1/impression/TOKEN
8
+ campaign_id: string;
9
+ size: string; // "300x250"
10
+ }
11
+
12
+ export class AdXensorApi {
13
+ constructor(
14
+ private readonly baseUrl: string, // e.g. "https://ads.adxensor.com/v1"
15
+ private readonly apiKey?: string,
16
+ ) {}
17
+
18
+ private headers(): Record<string, string> {
19
+ const h: Record<string, string> = { 'Content-Type': 'application/json' };
20
+ if (this.apiKey) h['X-Publisher-Key'] = this.apiKey;
21
+ return h;
22
+ }
23
+
24
+ // ─── Fetch the best ad for a slot ─────────────────────────────────────────
25
+ async serveAd(req: ServeRequest): Promise<AdResponse | null> {
26
+ try {
27
+ // Map SDK params → ads_core query schema
28
+ const params = new URLSearchParams({ zone: req.size, site: req.siteId });
29
+ if (req.url) params.set('url', req.url);
30
+ if (req.referrer) params.set('ref', req.referrer);
31
+
32
+ const res = await fetch(`${this.baseUrl}/ad?${params.toString()}`, {
33
+ method: 'GET',
34
+ headers: this.headers(),
35
+ });
36
+
37
+ // 204 = no ad available for this slot
38
+ if (res.status === 204 || !res.ok) return null;
39
+
40
+ const data = await res.json() as AdsCoreAdResponse;
41
+
42
+ // Extract shared event token from either tracking URL
43
+ const eventId = data.impression_url.split('/').pop() ?? '';
44
+
45
+ const [w, h] = (data.size ?? '300x250').split('x').map(Number);
46
+
47
+ return {
48
+ adId: eventId,
49
+ campaignId: data.campaign_id,
50
+ type: 'image',
51
+ imageUrl: data.creative_url,
52
+ clickHref: data.click_url, // full URL — use directly as <a> href
53
+ width: w ?? 300,
54
+ height: h ?? 250,
55
+ altText: '',
56
+ impressionToken: eventId,
57
+ clickToken: eventId,
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // ─── Fire-and-forget event (uses sendBeacon when available) ───────────────
65
+ sendEvent(path: string, payload: AdEventPayload | PageviewPayload): void {
66
+ const url = `${this.baseUrl}/events/${path}`;
67
+ const body = JSON.stringify(payload);
68
+
69
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
70
+ navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
71
+ } else {
72
+ fetch(url, {
73
+ method: 'POST',
74
+ headers: this.headers(),
75
+ body,
76
+ keepalive: true,
77
+ }).catch(() => { /* silent */ });
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,31 @@
1
+ import type { DeviceType } from './types.js';
2
+
3
+ export function getDevice(): DeviceType {
4
+ if (typeof window === 'undefined') return 'desktop';
5
+ const w = window.innerWidth;
6
+ if (w < 768) return 'mobile';
7
+ if (w < 1024) return 'tablet';
8
+ return 'desktop';
9
+ }
10
+
11
+ /**
12
+ * Resolve 'auto' to a concrete size based on the container width + device.
13
+ * Falls back to a safe default for each device tier.
14
+ */
15
+ export function resolveSize(requested: string, container: HTMLElement): string {
16
+ if (requested !== 'auto') return requested;
17
+
18
+ const device = getDevice();
19
+ const w = container.offsetWidth || window.innerWidth;
20
+
21
+ if (device === 'mobile') {
22
+ return w >= 320 ? '320x50' : '300x250';
23
+ }
24
+ if (device === 'tablet') {
25
+ return w >= 468 ? '468x60' : '300x250';
26
+ }
27
+ // desktop
28
+ if (w >= 728) return '728x90';
29
+ if (w >= 468) return '468x60';
30
+ return '300x250';
31
+ }
@@ -0,0 +1,40 @@
1
+ import type { AdResponse } from './types.js';
2
+
3
+ /**
4
+ * Inject the ad creative into `container`.
5
+ * - type 'html': raw HTML creative (rich media, animated banners)
6
+ * - type 'image': <a><img></a> wrapped in a click-tracked link
7
+ *
8
+ * The caller handles the click event via addEventListener; the rendered
9
+ * anchor uses href="#" so the tracker can intercept and open the real URL.
10
+ */
11
+ export function renderAd(container: HTMLElement, ad: AdResponse, clickHref: string): void {
12
+ container.style.overflow = 'hidden';
13
+ container.style.display = 'block';
14
+ container.style.lineHeight = '0'; // removes bottom gap under <img>
15
+
16
+ if (ad.type === 'html' && ad.htmlContent) {
17
+ container.innerHTML = ad.htmlContent;
18
+ return;
19
+ }
20
+
21
+ const a = document.createElement('a');
22
+ a.href = clickHref;
23
+ a.target = '_blank';
24
+ a.rel = 'noopener noreferrer';
25
+ a.setAttribute('data-adx-click', '1');
26
+ a.style.display = 'block';
27
+
28
+ const img = document.createElement('img');
29
+ img.src = ad.imageUrl ?? '';
30
+ img.width = ad.width;
31
+ img.height = ad.height;
32
+ img.alt = ad.altText || '';
33
+ img.style.display = 'block';
34
+ img.style.maxWidth = '100%';
35
+ img.loading = 'lazy';
36
+
37
+ a.appendChild(img);
38
+ container.innerHTML = '';
39
+ container.appendChild(a);
40
+ }
@@ -0,0 +1,38 @@
1
+ const SESSION_KEY = '_adx_sid';
2
+ const FIRED_KEY = '_adx_fired';
3
+
4
+ /** Returns (or creates) a session ID stored in sessionStorage. */
5
+ export function getSessionId(): string {
6
+ try {
7
+ let id = sessionStorage.getItem(SESSION_KEY);
8
+ if (!id) {
9
+ id = typeof crypto !== 'undefined' && crypto.randomUUID
10
+ ? crypto.randomUUID()
11
+ : Math.random().toString(36).slice(2) + Date.now().toString(36);
12
+ sessionStorage.setItem(SESSION_KEY, id);
13
+ }
14
+ return id;
15
+ } catch {
16
+ // sessionStorage blocked (iframe sandbox, private mode, etc.)
17
+ return Math.random().toString(36).slice(2);
18
+ }
19
+ }
20
+
21
+ /** Returns true if this event key was already fired this session. */
22
+ export function hasFired(key: string): boolean {
23
+ try {
24
+ const map = JSON.parse(sessionStorage.getItem(FIRED_KEY) || '{}') as Record<string, number>;
25
+ return key in map;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /** Mark an event key as fired. */
32
+ export function markFired(key: string): void {
33
+ try {
34
+ const map = JSON.parse(sessionStorage.getItem(FIRED_KEY) || '{}') as Record<string, number>;
35
+ map[key] = Date.now();
36
+ sessionStorage.setItem(FIRED_KEY, JSON.stringify(map));
37
+ } catch { /* ignore */ }
38
+ }