@freshjuice/zest 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +178 -78
  2. package/dist/zest.d.ts +214 -0
  3. package/dist/zest.de.js +692 -305
  4. package/dist/zest.de.js.map +1 -1
  5. package/dist/zest.de.min.js +1 -1
  6. package/dist/zest.en.js +692 -305
  7. package/dist/zest.en.js.map +1 -1
  8. package/dist/zest.en.min.js +1 -1
  9. package/dist/zest.es.js +692 -305
  10. package/dist/zest.es.js.map +1 -1
  11. package/dist/zest.es.min.js +1 -1
  12. package/dist/zest.esm.js +692 -305
  13. package/dist/zest.esm.js.map +1 -1
  14. package/dist/zest.esm.min.js +1 -1
  15. package/dist/zest.fr.js +692 -305
  16. package/dist/zest.fr.js.map +1 -1
  17. package/dist/zest.fr.min.js +1 -1
  18. package/dist/zest.headless.d.ts +178 -0
  19. package/dist/zest.headless.esm.js +2299 -0
  20. package/dist/zest.headless.esm.js.map +1 -0
  21. package/dist/zest.headless.esm.min.js +1 -0
  22. package/dist/zest.it.js +692 -305
  23. package/dist/zest.it.js.map +1 -1
  24. package/dist/zest.it.min.js +1 -1
  25. package/dist/zest.ja.js +692 -305
  26. package/dist/zest.ja.js.map +1 -1
  27. package/dist/zest.ja.min.js +1 -1
  28. package/dist/zest.js +692 -305
  29. package/dist/zest.js.map +1 -1
  30. package/dist/zest.min.js +1 -1
  31. package/dist/zest.nl.js +692 -305
  32. package/dist/zest.nl.js.map +1 -1
  33. package/dist/zest.nl.min.js +1 -1
  34. package/dist/zest.pl.js +692 -305
  35. package/dist/zest.pl.js.map +1 -1
  36. package/dist/zest.pl.min.js +1 -1
  37. package/dist/zest.pt.js +692 -305
  38. package/dist/zest.pt.js.map +1 -1
  39. package/dist/zest.pt.min.js +1 -1
  40. package/dist/zest.ru.js +692 -305
  41. package/dist/zest.ru.js.map +1 -1
  42. package/dist/zest.ru.min.js +1 -1
  43. package/dist/zest.uk.js +692 -305
  44. package/dist/zest.uk.js.map +1 -1
  45. package/dist/zest.uk.min.js +1 -1
  46. package/dist/zest.zh.js +692 -305
  47. package/dist/zest.zh.js.map +1 -1
  48. package/dist/zest.zh.min.js +1 -1
  49. package/package.json +23 -4
  50. package/src/core/cookie-interceptor.js +20 -5
  51. package/src/core/known-trackers.js +41 -14
  52. package/src/core/pattern-matcher.js +20 -5
  53. package/src/core/script-blocker.js +85 -79
  54. package/src/core/security.js +204 -0
  55. package/src/core/storage-interceptor.js +5 -1
  56. package/src/core-lifecycle.js +192 -0
  57. package/src/headless.js +133 -0
  58. package/src/index.js +73 -184
  59. package/src/storage/consent-store.js +32 -8
  60. package/src/types/zest.d.ts +214 -0
  61. package/src/types/zest.headless.d.ts +178 -0
  62. package/src/ui/banner.js +11 -7
  63. package/src/ui/modal.js +16 -12
  64. package/src/ui/styles.js +25 -4
  65. package/src/ui/widget.js +3 -1
@@ -0,0 +1,2299 @@
1
+ /**
2
+ * Security utilities - escaping, validation, and safe parsing helpers
3
+ *
4
+ * These helpers are used across UI components, URL/CSS validation, and
5
+ * consent-cookie parsing to provide defense-in-depth against untrusted
6
+ * config (CMS-driven, i18n-loaded, or attacker-supplied).
7
+ */
8
+
9
+
10
+ /**
11
+ * Validate a regex pattern string. Rejects patterns that contain known
12
+ * catastrophic-backtracking shapes (nested quantifiers). Compiles with
13
+ * try/catch.
14
+ *
15
+ * Returns a RegExp on success, null on failure.
16
+ */
17
+ const REDOS_PATTERNS = [
18
+ /(\([^)]*[+*][^)]*\)|\[[^\]]*\]|\\w|\\d|\\s)\s*[+*]/, // nested quantifier
19
+ /\(\?[=!][^)]*[+*][^)]*\)[+*]/, // lookahead with quantifier, then quantifier
20
+ ];
21
+
22
+ function safeRegExp(pattern, flags) {
23
+ if (pattern instanceof RegExp) return pattern;
24
+ if (typeof pattern !== 'string') return null;
25
+
26
+ // Cap pattern length to limit compiled-regex state
27
+ if (pattern.length > 500) return null;
28
+
29
+ // Heuristic: reject obviously dangerous patterns
30
+ for (const bad of REDOS_PATTERNS) {
31
+ if (bad.test(pattern)) return null;
32
+ }
33
+
34
+ try {
35
+ return new RegExp(pattern, flags);
36
+ } catch (e) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Sanitize a consent-cookie payload. Only known category keys with
43
+ * boolean values survive; prototype-polluting keys are stripped.
44
+ */
45
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
+
47
+ function sanitizeConsentPayload(raw, knownCategoryIds) {
48
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
49
+
50
+ const result = {
51
+ version: typeof raw.version === 'string' ? raw.version : null,
52
+ timestamp: typeof raw.timestamp === 'number' && Number.isFinite(raw.timestamp) ? raw.timestamp : null,
53
+ categories: {}
54
+ };
55
+
56
+ const cats = raw.categories;
57
+ if (!cats || typeof cats !== 'object' || Array.isArray(cats)) return null;
58
+
59
+ for (const key of knownCategoryIds) {
60
+ if (FORBIDDEN_KEYS.has(key)) continue;
61
+ if (Object.prototype.hasOwnProperty.call(cats, key)) {
62
+ result.categories[key] = cats[key] === true;
63
+ }
64
+ }
65
+
66
+ // essential is always true regardless of stored value
67
+ if (knownCategoryIds.includes('essential')) {
68
+ result.categories.essential = true;
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Invoke a user-supplied callback, swallowing and logging exceptions so
76
+ * a misbehaving callback can't break the consent flow.
77
+ */
78
+ function safeInvoke(fn, ...args) {
79
+ if (typeof fn !== 'function') return undefined;
80
+ try {
81
+ return fn(...args);
82
+ } catch (e) {
83
+ try {
84
+ console.error('[Zest] User callback threw:', e);
85
+ } catch (_) {
86
+ /* no-op */
87
+ }
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Pattern Matcher - Categorizes cookies and storage keys by pattern
94
+ */
95
+
96
+
97
+ /**
98
+ * Default patterns for each category
99
+ */
100
+ const DEFAULT_PATTERNS = {
101
+ essential: [
102
+ /^zest_/,
103
+ /^csrf/i,
104
+ /^xsrf/i,
105
+ /^session/i,
106
+ /^__host-/i,
107
+ /^__secure-/i
108
+ ],
109
+ functional: [
110
+ /^lang/i,
111
+ /^locale/i,
112
+ /^theme/i,
113
+ /^preferences/i,
114
+ /^ui_/i
115
+ ],
116
+ analytics: [
117
+ /^_ga/,
118
+ /^_gid/,
119
+ /^_gat/,
120
+ /^_utm/,
121
+ /^__utm/,
122
+ /^plausible/i,
123
+ /^_pk_/,
124
+ /^matomo/i,
125
+ /^_hj/,
126
+ /^ajs_/
127
+ ],
128
+ marketing: [
129
+ /^_fbp/,
130
+ /^_fbc/,
131
+ /^_gcl/,
132
+ /^_ttp/,
133
+ /^ads/i,
134
+ /^doubleclick/i,
135
+ /^__gads/,
136
+ /^__gpi/,
137
+ /^_pin_/,
138
+ /^li_/
139
+ ]
140
+ };
141
+
142
+ let patterns = { ...DEFAULT_PATTERNS };
143
+
144
+ /**
145
+ * Set custom patterns. User-supplied strings are validated with safeRegExp,
146
+ * which rejects catastrophic-backtracking shapes and syntax errors.
147
+ * Invalid patterns are silently dropped with a console warning.
148
+ */
149
+ function setPatterns(customPatterns) {
150
+ patterns = { ...DEFAULT_PATTERNS };
151
+ if (!customPatterns || typeof customPatterns !== 'object') return;
152
+
153
+ for (const [category, regexList] of Object.entries(customPatterns)) {
154
+ if (!Array.isArray(regexList)) continue;
155
+
156
+ const compiled = [];
157
+ for (const p of regexList) {
158
+ const re = safeRegExp(p);
159
+ if (re) {
160
+ compiled.push(re);
161
+ } else {
162
+ try {
163
+ console.warn('[Zest] Rejected unsafe pattern:', p);
164
+ } catch (_) { /* no-op */ }
165
+ }
166
+ }
167
+ patterns[category] = compiled;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Determine category for a cookie/storage key name
173
+ * @param {string} name - Cookie or storage key name
174
+ * @returns {string} Category ID (defaults to 'marketing' for unknown)
175
+ */
176
+ function getCategoryForName(name) {
177
+ for (const [category, regexList] of Object.entries(patterns)) {
178
+ if (regexList.some(regex => regex.test(name))) {
179
+ return category;
180
+ }
181
+ }
182
+ // Unknown items default to marketing (strictest)
183
+ return 'marketing';
184
+ }
185
+
186
+ /**
187
+ * Parse cookie string to extract name
188
+ * @param {string} cookieString - Full cookie string (e.g., "name=value; path=/")
189
+ * @returns {string|null} Cookie name or null
190
+ */
191
+ function parseCookieName(cookieString) {
192
+ const match = cookieString.match(/^([^=]+)/);
193
+ return match ? match[1].trim() : null;
194
+ }
195
+
196
+ /**
197
+ * Cookie Interceptor - Intercepts document.cookie operations
198
+ */
199
+
200
+
201
+ // Store original descriptor
202
+ let originalCookieDescriptor = null;
203
+
204
+ // Upper bound on the number of queued cookies awaiting consent replay.
205
+ // An unbounded queue is a memory-exhaustion DoS vector — a hostile
206
+ // script could flood it with document.cookie writes.
207
+ const MAX_QUEUE_SIZE$2 = 100;
208
+
209
+ // Queue for blocked cookies
210
+ const cookieQueue = [];
211
+
212
+ // Reference to consent checker function
213
+ let checkConsent$3 = () => false;
214
+
215
+ /**
216
+ * Set the consent checker function
217
+ */
218
+ function setConsentChecker$2(fn) {
219
+ checkConsent$3 = fn;
220
+ }
221
+
222
+ /**
223
+ * Get the original cookie descriptor
224
+ */
225
+ function getOriginalCookieDescriptor() {
226
+ return originalCookieDescriptor;
227
+ }
228
+
229
+ /**
230
+ * Replay queued cookies for allowed categories
231
+ */
232
+ function replayCookies(allowedCategories) {
233
+ const remaining = [];
234
+
235
+ for (const item of cookieQueue) {
236
+ if (allowedCategories.includes(item.category)) {
237
+ // Set the cookie using original setter
238
+ if (originalCookieDescriptor?.set) {
239
+ originalCookieDescriptor.set.call(document, item.value);
240
+ }
241
+ } else {
242
+ remaining.push(item);
243
+ }
244
+ }
245
+
246
+ cookieQueue.length = 0;
247
+ cookieQueue.push(...remaining);
248
+ }
249
+
250
+ /**
251
+ * Start intercepting cookies
252
+ */
253
+ function interceptCookies() {
254
+ // Store original
255
+ originalCookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
256
+
257
+ if (!originalCookieDescriptor) {
258
+ console.warn('[Zest] Could not get cookie descriptor');
259
+ return false;
260
+ }
261
+
262
+ Object.defineProperty(document, 'cookie', {
263
+ get() {
264
+ // Always allow reading
265
+ return originalCookieDescriptor.get.call(document);
266
+ },
267
+ set(value) {
268
+ const name = parseCookieName(value);
269
+ if (!name) {
270
+ return;
271
+ }
272
+
273
+ const category = getCategoryForName(name);
274
+
275
+ if (checkConsent$3(category)) {
276
+ // Consent given - set cookie
277
+ originalCookieDescriptor.set.call(document, value);
278
+ } else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
279
+ // No consent - queue for later (capped to prevent DoS)
280
+ cookieQueue.push({
281
+ value,
282
+ name,
283
+ category,
284
+ timestamp: Date.now()
285
+ });
286
+ }
287
+ },
288
+ // configurable: false prevents a later-loaded script from
289
+ // overriding our descriptor and bypassing the interceptor.
290
+ configurable: false
291
+ });
292
+
293
+ return true;
294
+ }
295
+
296
+ /**
297
+ * Storage Interceptor - Intercepts localStorage and sessionStorage operations
298
+ */
299
+
300
+
301
+ // Upper bound on queued operations awaiting consent replay — unbounded
302
+ // growth would be a memory-exhaustion DoS vector.
303
+ const MAX_QUEUE_SIZE$1 = 200;
304
+
305
+ // Store originals
306
+ let originalLocalStorage = null;
307
+ let originalSessionStorage = null;
308
+
309
+ // Queues for blocked operations
310
+ const localStorageQueue = [];
311
+ const sessionStorageQueue = [];
312
+
313
+ // Reference to consent checker function
314
+ let checkConsent$2 = () => false;
315
+
316
+ /**
317
+ * Set the consent checker function
318
+ */
319
+ function setConsentChecker$1(fn) {
320
+ checkConsent$2 = fn;
321
+ }
322
+
323
+ /**
324
+ * Create a proxy for storage API
325
+ */
326
+ function createStorageProxy(storage, queue, storageName) {
327
+ return new Proxy(storage, {
328
+ get(target, prop) {
329
+ if (prop === 'setItem') {
330
+ return (key, value) => {
331
+ const category = getCategoryForName(key);
332
+
333
+ if (checkConsent$2(category)) {
334
+ target.setItem(key, value);
335
+ } else if (queue.length < MAX_QUEUE_SIZE$1) {
336
+ queue.push({
337
+ key,
338
+ value,
339
+ category,
340
+ timestamp: Date.now()
341
+ });
342
+ }
343
+ };
344
+ }
345
+
346
+ // Allow all other operations
347
+ const val = target[prop];
348
+ return typeof val === 'function' ? val.bind(target) : val;
349
+ }
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Replay queued storage operations for allowed categories
355
+ */
356
+ function replayStorage(allowedCategories) {
357
+ // Replay localStorage
358
+ const remainingLocal = [];
359
+ for (const item of localStorageQueue) {
360
+ if (allowedCategories.includes(item.category)) {
361
+ originalLocalStorage?.setItem(item.key, item.value);
362
+ } else {
363
+ remainingLocal.push(item);
364
+ }
365
+ }
366
+ localStorageQueue.length = 0;
367
+ localStorageQueue.push(...remainingLocal);
368
+
369
+ // Replay sessionStorage
370
+ const remainingSession = [];
371
+ for (const item of sessionStorageQueue) {
372
+ if (allowedCategories.includes(item.category)) {
373
+ originalSessionStorage?.setItem(item.key, item.value);
374
+ } else {
375
+ remainingSession.push(item);
376
+ }
377
+ }
378
+ sessionStorageQueue.length = 0;
379
+ sessionStorageQueue.push(...remainingSession);
380
+ }
381
+
382
+ /**
383
+ * Start intercepting storage APIs
384
+ */
385
+ function interceptStorage() {
386
+ try {
387
+ originalLocalStorage = window.localStorage;
388
+ originalSessionStorage = window.sessionStorage;
389
+
390
+ Object.defineProperty(window, 'localStorage', {
391
+ value: createStorageProxy(originalLocalStorage, localStorageQueue, 'localStorage'),
392
+ configurable: true,
393
+ writable: false
394
+ });
395
+
396
+ Object.defineProperty(window, 'sessionStorage', {
397
+ value: createStorageProxy(originalSessionStorage, sessionStorageQueue, 'sessionStorage'),
398
+ configurable: true,
399
+ writable: false
400
+ });
401
+
402
+ return true;
403
+ } catch (e) {
404
+ console.warn('[Zest] Could not intercept storage APIs:', e);
405
+ return false;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Known Trackers - Lists of known tracking script domains by category
411
+ */
412
+
413
+ /**
414
+ * Safe mode - Major, well-known trackers only
415
+ */
416
+ const SAFE_TRACKERS = {
417
+ analytics: [
418
+ 'google-analytics.com',
419
+ 'www.google-analytics.com',
420
+ 'analytics.google.com',
421
+ 'googletagmanager.com',
422
+ 'www.googletagmanager.com',
423
+ 'plausible.io',
424
+ 'cloudflareinsights.com',
425
+ 'static.cloudflareinsights.com'
426
+ ],
427
+ marketing: [
428
+ 'connect.facebook.net',
429
+ 'www.facebook.com/tr',
430
+ 'ads.google.com',
431
+ 'www.googleadservices.com',
432
+ 'googleads.g.doubleclick.net',
433
+ 'pagead2.googlesyndication.com'
434
+ ]
435
+ };
436
+
437
+ /**
438
+ * Strict mode - Extended list including less common trackers
439
+ */
440
+ const STRICT_TRACKERS = {
441
+ analytics: [
442
+ ...SAFE_TRACKERS.analytics,
443
+ 'analytics.tiktok.com',
444
+ 'matomo.', // partial match
445
+ 'hotjar.com',
446
+ 'static.hotjar.com',
447
+ 'script.hotjar.com',
448
+ 'clarity.ms',
449
+ 'www.clarity.ms',
450
+ 'heapanalytics.com',
451
+ 'cdn.heapanalytics.com',
452
+ 'mixpanel.com',
453
+ 'cdn.mxpnl.com',
454
+ 'segment.com',
455
+ 'cdn.segment.com',
456
+ 'api.segment.io',
457
+ 'fullstory.com',
458
+ 'rs.fullstory.com',
459
+ 'amplitude.com',
460
+ 'cdn.amplitude.com',
461
+ 'mouseflow.com',
462
+ 'cdn.mouseflow.com',
463
+ 'luckyorange.com',
464
+ 'cdn.luckyorange.net',
465
+ 'crazyegg.com',
466
+ 'script.crazyegg.com'
467
+ ],
468
+ marketing: [
469
+ ...SAFE_TRACKERS.marketing,
470
+ 'snap.licdn.com',
471
+ 'px.ads.linkedin.com',
472
+ 'ads.linkedin.com',
473
+ 'analytics.twitter.com',
474
+ 'static.ads-twitter.com',
475
+ 't.co',
476
+ 'analytics.tiktok.com',
477
+ 'ads.tiktok.com',
478
+ 'sc-static.net', // Snapchat
479
+ 'tr.snapchat.com',
480
+ 'ct.pinterest.com',
481
+ 'pintrk.com',
482
+ 's.pinimg.com',
483
+ 'widgets.pinterest.com',
484
+ 'bat.bing.com',
485
+ 'ads.yahoo.com',
486
+ 'sp.analytics.yahoo.com',
487
+ 'amazon-adsystem.com',
488
+ 'z-na.amazon-adsystem.com',
489
+ 'criteo.com',
490
+ 'static.criteo.net',
491
+ 'dis.criteo.com',
492
+ 'taboola.com',
493
+ 'cdn.taboola.com',
494
+ 'trc.taboola.com',
495
+ 'outbrain.com',
496
+ 'widgets.outbrain.com',
497
+ 'adroll.com',
498
+ 's.adroll.com'
499
+ ],
500
+ functional: [
501
+ 'cdn.onesignal.com',
502
+ 'onesignal.com',
503
+ 'pusher.com',
504
+ 'js.pusher.com',
505
+ 'intercom.io',
506
+ 'widget.intercom.io',
507
+ 'js.intercomcdn.com',
508
+ 'crisp.chat',
509
+ 'client.crisp.chat',
510
+ 'cdn.livechatinc.com',
511
+ 'livechatinc.com',
512
+ 'tawk.to',
513
+ 'embed.tawk.to',
514
+ 'zendesk.com',
515
+ 'static.zdassets.com'
516
+ ]
517
+ };
518
+
519
+ /**
520
+ * Check if a URL matches any tracker in the list.
521
+ *
522
+ * Matching is restricted to hostname (and, when the list entry contains
523
+ * a path, the URL path prefix). A naive `fullUrl.includes(domain)` was
524
+ * previously used, which would false-positive on e.g.
525
+ * https://mysite.com/page?ref=google-analytics.com
526
+ */
527
+ function matchesTrackerList(url, trackerList) {
528
+ let urlObj;
529
+ try {
530
+ urlObj = new URL(url);
531
+ } catch (e) {
532
+ return false;
533
+ }
534
+ const hostname = urlObj.hostname.toLowerCase();
535
+ const path = urlObj.pathname.toLowerCase();
536
+
537
+ for (const rawEntry of trackerList) {
538
+ if (typeof rawEntry !== 'string') continue;
539
+ const entry = rawEntry.toLowerCase();
540
+
541
+ // Partial-prefix match on hostname (entry ends with a dot),
542
+ // e.g. "matomo." matches "analytics.matomo.cloud"
543
+ if (entry.endsWith('.')) {
544
+ const needle = entry.slice(0, -1);
545
+ const segments = hostname.split('.');
546
+ if (segments.some(seg => seg === needle) || hostname.startsWith(entry)) {
547
+ return true;
548
+ }
549
+ continue;
550
+ }
551
+
552
+ // Entries containing a slash specify hostname + path prefix
553
+ const slashIdx = entry.indexOf('/');
554
+ if (slashIdx !== -1) {
555
+ const entryHost = entry.slice(0, slashIdx);
556
+ const entryPath = entry.slice(slashIdx);
557
+ if ((hostname === entryHost || hostname.endsWith('.' + entryHost)) &&
558
+ path.startsWith(entryPath)) {
559
+ return true;
560
+ }
561
+ continue;
562
+ }
563
+
564
+ // Plain hostname: exact or subdomain match only
565
+ if (hostname === entry || hostname.endsWith('.' + entry)) {
566
+ return true;
567
+ }
568
+ }
569
+
570
+ return false;
571
+ }
572
+
573
+ /**
574
+ * Get category for a script URL based on tracker lists
575
+ */
576
+ function getCategoryForScript(url, mode = 'safe') {
577
+ const trackers = mode === 'strict' ? STRICT_TRACKERS : SAFE_TRACKERS;
578
+
579
+ for (const [category, domains] of Object.entries(trackers)) {
580
+ if (matchesTrackerList(url, domains)) {
581
+ return category;
582
+ }
583
+ }
584
+
585
+ return null;
586
+ }
587
+
588
+ /**
589
+ * Check if URL is third-party (different domain)
590
+ */
591
+ function isThirdParty(url) {
592
+ try {
593
+ const scriptHost = new URL(url).hostname;
594
+ const pageHost = window.location.hostname;
595
+
596
+ // Remove www. for comparison
597
+ const normalizeHost = (h) => h.replace(/^www\./, '');
598
+
599
+ return normalizeHost(scriptHost) !== normalizeHost(pageHost);
600
+ } catch (e) {
601
+ return false;
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Script Blocker - Blocks and manages consent-gated scripts
607
+ *
608
+ * Modes:
609
+ * - manual: Only blocks scripts with data-consent-category attribute
610
+ * - safe: Manual + known major trackers (Google, Facebook, etc.)
611
+ * - strict: Safe + extended tracker list (Hotjar, Mixpanel, etc.)
612
+ * - doomsday: Block ALL third-party scripts
613
+ */
614
+
615
+
616
+ // Categories the author has declared blockable. A script can self-label
617
+ // into one of these, but not into 'essential' (a common bypass).
618
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
619
+
620
+ // Upper bound on queued scripts awaiting consent replay — prevents a
621
+ // hostile page from flooding the queue with <script> nodes.
622
+ const MAX_QUEUE_SIZE = 500;
623
+
624
+ // Queue for blocked scripts — the authoritative source for replay,
625
+ // snapshotting src/inline BEFORE any DOM mutation so later tampering
626
+ // cannot hijack what gets executed.
627
+ const scriptQueue = [];
628
+
629
+ // MutationObserver instance
630
+ let observer = null;
631
+
632
+ // Current blocking mode
633
+ let blockingMode = 'safe';
634
+
635
+ // Custom blocked domains (user-defined)
636
+ let customBlockedDomains = [];
637
+
638
+ // Reference to consent checker function
639
+ let checkConsent$1 = () => false;
640
+
641
+ /**
642
+ * Set the consent checker function
643
+ */
644
+ function setConsentChecker(fn) {
645
+ checkConsent$1 = fn;
646
+ }
647
+
648
+ /**
649
+ * Check if script URL matches custom blocked domains
650
+ */
651
+ function matchesCustomDomains(url) {
652
+ if (!url || customBlockedDomains.length === 0) return null;
653
+
654
+ try {
655
+ const hostname = new URL(url).hostname.toLowerCase();
656
+
657
+ for (const entry of customBlockedDomains) {
658
+ const domain = typeof entry === 'string' ? entry : entry.domain;
659
+ const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
660
+
661
+ if (hostname === domain || hostname.endsWith('.' + domain)) {
662
+ return category;
663
+ }
664
+ }
665
+ } catch (e) {
666
+ // Invalid URL
667
+ }
668
+
669
+ return null;
670
+ }
671
+
672
+ /**
673
+ * Determine if a script should be blocked and get its category.
674
+ *
675
+ * A self-applied 'essential' label is ignored — only explicit blockable
676
+ * categories are accepted. That prevents a third-party script from
677
+ * stamping itself with data-consent-category="essential" to slip past
678
+ * mode-based blocking.
679
+ */
680
+ function getScriptBlockCategory(script) {
681
+ // Skip if script has data-zest-allow attribute (opt-out)
682
+ if (script.hasAttribute('data-zest-allow')) {
683
+ return null;
684
+ }
685
+
686
+ // 1. Check for explicit data-consent-category attribute.
687
+ // Only honor values from the blockable set; 'essential' and unknown
688
+ // values fall through to the other checks.
689
+ const explicitCategory = script.getAttribute('data-consent-category');
690
+ const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
691
+ ? explicitCategory
692
+ : null;
693
+
694
+ const src = script.src;
695
+
696
+ // No src = inline script, only block if explicitly tagged (blockable only)
697
+ if (!src) {
698
+ return explicitBlockable;
699
+ }
700
+
701
+ // 2. Check custom blocked domains
702
+ const customCategory = matchesCustomDomains(src);
703
+
704
+ // 3. Mode-based blocking
705
+ let modeCategory = null;
706
+ switch (blockingMode) {
707
+ case 'manual':
708
+ break;
709
+
710
+ case 'safe':
711
+ case 'strict':
712
+ modeCategory = getCategoryForScript(src, blockingMode);
713
+ break;
714
+
715
+ case 'doomsday':
716
+ if (isThirdParty(src)) {
717
+ modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
718
+ }
719
+ break;
720
+ }
721
+
722
+ // Use the strictest category among explicit/custom/mode decisions.
723
+ // We collect all categories the script matches and pick the first
724
+ // that appears in the blockable set (any match wins — but we prefer
725
+ // the mode-assigned one since it's authoritative for third-party
726
+ // trackers that try to self-label as 'functional').
727
+ return modeCategory || customCategory || explicitBlockable;
728
+ }
729
+
730
+ /**
731
+ * Block a script element
732
+ */
733
+ function blockScript(script) {
734
+ // Skip already processed scripts
735
+ if (script.hasAttribute('data-zest-processed')) {
736
+ return false;
737
+ }
738
+
739
+ const category = getScriptBlockCategory(script);
740
+
741
+ if (!category) {
742
+ script.setAttribute('data-zest-processed', 'allowed');
743
+ return false;
744
+ }
745
+
746
+ if (checkConsent$1(category)) {
747
+ // Consent already given - allow script
748
+ script.setAttribute('data-zest-processed', 'allowed');
749
+ return false;
750
+ }
751
+
752
+ // Store script info for later execution. Snapshot the src/text BEFORE
753
+ // mutating the DOM — this snapshot is the authoritative replay source
754
+ // so later DOM tampering cannot hijack the replayed script URL.
755
+ const scriptInfo = {
756
+ category,
757
+ src: script.src || '',
758
+ inline: script.textContent,
759
+ type: script.type,
760
+ async: script.async,
761
+ defer: script.defer,
762
+ element: script,
763
+ timestamp: Date.now()
764
+ };
765
+
766
+ // Mark as processed
767
+ script.setAttribute('data-zest-processed', 'blocked');
768
+ script.setAttribute('data-consent-category', category);
769
+
770
+ // Disable the script
771
+ script.type = 'text/plain';
772
+
773
+ // Remove src to prevent loading. We no longer stash it on the element
774
+ // (data-blocked-src was a tampering vector); scriptQueue is the single
775
+ // source of truth for replay.
776
+ if (script.src) {
777
+ script.removeAttribute('src');
778
+ }
779
+
780
+ if (scriptQueue.length < MAX_QUEUE_SIZE) {
781
+ scriptQueue.push(scriptInfo);
782
+ }
783
+ return true;
784
+ }
785
+
786
+ /**
787
+ * Replay queued scripts for allowed categories.
788
+ *
789
+ * scriptQueue is the single source of truth for src and inline body —
790
+ * we never re-read data-* attributes from the DOM (which an attacker
791
+ * could have rewritten in the intervening time).
792
+ */
793
+ function replayScripts(allowedCategories) {
794
+ const remaining = [];
795
+
796
+ for (const scriptInfo of scriptQueue) {
797
+ if (!allowedCategories.includes(scriptInfo.category)) {
798
+ remaining.push(scriptInfo);
799
+ continue;
800
+ }
801
+
802
+ const newScript = document.createElement('script');
803
+ if (scriptInfo.src) {
804
+ newScript.src = scriptInfo.src;
805
+ } else if (scriptInfo.inline) {
806
+ newScript.textContent = scriptInfo.inline;
807
+ }
808
+ if (scriptInfo.async) newScript.async = true;
809
+ if (scriptInfo.defer) newScript.defer = true;
810
+ if (scriptInfo.type && scriptInfo.type !== 'text/plain') {
811
+ newScript.type = scriptInfo.type;
812
+ }
813
+ newScript.setAttribute('data-zest-processed', 'executed');
814
+ newScript.setAttribute('data-consent-executed', 'true');
815
+
816
+ // If the original element is still in the DOM, replace it in place
817
+ // so execution order is preserved. Otherwise append to <head>.
818
+ const original = scriptInfo.element;
819
+ if (original && original.isConnected && original.parentNode) {
820
+ original.parentNode.replaceChild(newScript, original);
821
+ } else {
822
+ document.head.appendChild(newScript);
823
+ }
824
+ }
825
+
826
+ scriptQueue.length = 0;
827
+ scriptQueue.push(...remaining);
828
+ }
829
+
830
+ /**
831
+ * Process existing scripts in the DOM
832
+ */
833
+ function processExistingScripts() {
834
+ const scripts = document.querySelectorAll('script:not([data-zest-processed])');
835
+ scripts.forEach(blockScript);
836
+ }
837
+
838
+ /**
839
+ * Handle mutations (new scripts added to DOM)
840
+ */
841
+ function handleMutations(mutations) {
842
+ for (const mutation of mutations) {
843
+ for (const node of mutation.addedNodes) {
844
+ if (node.nodeName === 'SCRIPT' && !node.hasAttribute('data-zest-processed')) {
845
+ blockScript(node);
846
+ }
847
+
848
+ // Check child scripts
849
+ if (node.querySelectorAll) {
850
+ const scripts = node.querySelectorAll('script:not([data-zest-processed])');
851
+ scripts.forEach(blockScript);
852
+ }
853
+ }
854
+ }
855
+ }
856
+
857
+ /**
858
+ * Start observing for new scripts
859
+ */
860
+ function startScriptBlocking(mode = 'safe', customDomains = []) {
861
+ blockingMode = mode;
862
+ customBlockedDomains = customDomains;
863
+
864
+ // Process existing scripts
865
+ processExistingScripts();
866
+
867
+ // Watch for new scripts
868
+ observer = new MutationObserver(handleMutations);
869
+
870
+ observer.observe(document.documentElement, {
871
+ childList: true,
872
+ subtree: true
873
+ });
874
+
875
+ return true;
876
+ }
877
+
878
+ /**
879
+ * Default consent categories
880
+ */
881
+ const DEFAULT_CATEGORIES = {
882
+ essential: {
883
+ id: 'essential',
884
+ label: 'Essential',
885
+ description: 'Required for the website to function properly. Cannot be disabled.',
886
+ required: true,
887
+ default: true
888
+ },
889
+ functional: {
890
+ id: 'functional',
891
+ label: 'Functional',
892
+ description: 'Enable personalized features like language preferences and themes.',
893
+ required: false,
894
+ default: false
895
+ },
896
+ analytics: {
897
+ id: 'analytics',
898
+ label: 'Analytics',
899
+ description: 'Help us understand how visitors interact with our website.',
900
+ required: false,
901
+ default: false
902
+ },
903
+ marketing: {
904
+ id: 'marketing',
905
+ label: 'Marketing',
906
+ description: 'Used to deliver relevant advertisements and track campaign performance.',
907
+ required: false,
908
+ default: false
909
+ }
910
+ };
911
+
912
+ /**
913
+ * Default consent state
914
+ */
915
+ function getDefaultConsent() {
916
+ return {
917
+ essential: true,
918
+ functional: false,
919
+ analytics: false,
920
+ marketing: false
921
+ };
922
+ }
923
+
924
+ /**
925
+ * Get all category IDs
926
+ */
927
+ function getCategoryIds() {
928
+ return Object.keys(DEFAULT_CATEGORIES);
929
+ }
930
+
931
+ /**
932
+ * Do Not Track (DNT) Detection
933
+ *
934
+ * Detects browser DNT/GPC signals for privacy compliance
935
+ */
936
+
937
+ /**
938
+ * Check if Do Not Track is enabled
939
+ * Checks both DNT header and Global Privacy Control (GPC)
940
+ */
941
+ function isDoNotTrackEnabled() {
942
+ if (typeof navigator === 'undefined') {
943
+ return false;
944
+ }
945
+
946
+ // Check DNT (Do Not Track)
947
+ // Values: "1" = enabled, "0" = disabled, null/undefined = not set
948
+ const dnt = navigator.doNotTrack ||
949
+ window.doNotTrack ||
950
+ navigator.msDoNotTrack;
951
+
952
+ if (dnt === '1' || dnt === 'yes' || dnt === true) {
953
+ return true;
954
+ }
955
+
956
+ // Check GPC (Global Privacy Control) - newer standard
957
+ // https://globalprivacycontrol.org/
958
+ if (navigator.globalPrivacyControl === true) {
959
+ return true;
960
+ }
961
+
962
+ return false;
963
+ }
964
+
965
+ /**
966
+ * Get DNT signal details for logging/debugging
967
+ */
968
+ function getDNTDetails() {
969
+ if (typeof navigator === 'undefined') {
970
+ return { enabled: false, source: null };
971
+ }
972
+
973
+ const dnt = navigator.doNotTrack ||
974
+ window.doNotTrack ||
975
+ navigator.msDoNotTrack;
976
+
977
+ if (dnt === '1' || dnt === 'yes' || dnt === true) {
978
+ return { enabled: true, source: 'dnt' };
979
+ }
980
+
981
+ if (navigator.globalPrivacyControl === true) {
982
+ return { enabled: true, source: 'gpc' };
983
+ }
984
+
985
+ return { enabled: false, source: null };
986
+ }
987
+
988
+ /**
989
+ * Consent Signals - Optional vendor consent mode integrations
990
+ *
991
+ * Pushes consent state to Google Consent Mode v2 and/or Microsoft UET
992
+ * Consent Mode when enabled via config.
993
+ */
994
+
995
+ /**
996
+ * Map Zest consent state to Google Consent Mode v2 signals
997
+ */
998
+ function toGoogleSignals(consent) {
999
+ const g = (val) => val ? 'granted' : 'denied';
1000
+ return {
1001
+ ad_storage: g(consent.marketing),
1002
+ ad_user_data: g(consent.marketing),
1003
+ ad_personalization: g(consent.marketing),
1004
+ analytics_storage: g(consent.analytics),
1005
+ functionality_storage: 'granted', // essential is always true
1006
+ personalization_storage: g(consent.functional)
1007
+ };
1008
+ }
1009
+
1010
+ /**
1011
+ * Push consent signal to Google via gtag or dataLayer fallback.
1012
+ * Uses a local function to preserve the `arguments` object shape
1013
+ * that gtag/dataLayer expects (not an array).
1014
+ */
1015
+ function pushGoogle(type, signals) {
1016
+ window.dataLayer = window.dataLayer || [];
1017
+ if (typeof window.gtag === 'function') {
1018
+ window.gtag('consent', type, signals);
1019
+ } else {
1020
+ function gtagFallback() { window.dataLayer.push(arguments); }
1021
+ gtagFallback('consent', type, signals);
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Map Zest consent state to Microsoft UET signal.
1027
+ * Microsoft UET only exposes ad_storage.
1028
+ */
1029
+ function toMicrosoftSignals(consent) {
1030
+ return { ad_storage: consent.marketing ? 'granted' : 'denied' };
1031
+ }
1032
+
1033
+ /**
1034
+ * Push consent signal to Microsoft UET
1035
+ */
1036
+ function pushMicrosoft(type, signals) {
1037
+ window.uetq = window.uetq || [];
1038
+ window.uetq.push('consent', type, signals);
1039
+ }
1040
+
1041
+ /**
1042
+ * Apply consent signals to enabled vendor integrations.
1043
+ *
1044
+ * @param {Object} consent Current Zest consent state
1045
+ * @param {Object} config Merged Zest config
1046
+ * @param {boolean} isDefault true on first call (pushes 'default'), false for updates
1047
+ */
1048
+ function applyConsentSignals(consent, config, isDefault) {
1049
+ const type = isDefault ? 'default' : 'update';
1050
+
1051
+ if (config.consentModeGoogle) {
1052
+ pushGoogle(type, toGoogleSignals(consent));
1053
+ }
1054
+
1055
+ if (config.consentModeMicrosoft) {
1056
+ pushMicrosoft(type, toMicrosoftSignals(consent));
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Built-in translations for Zest
1062
+ * Language is auto-detected from <html lang=""> or navigator.language
1063
+ */
1064
+
1065
+ const translations = {
1066
+ en: {
1067
+ labels: {
1068
+ banner: {
1069
+ title: 'We value your privacy',
1070
+ description: 'We use cookies to enhance your browsing experience, serve personalized content, and analyze our traffic. By clicking "Accept All", you consent to our use of cookies.',
1071
+ acceptAll: 'Accept All',
1072
+ rejectAll: 'Reject All',
1073
+ settings: 'Settings'
1074
+ },
1075
+ modal: {
1076
+ title: 'Privacy Settings',
1077
+ description: 'Manage your cookie preferences. You can enable or disable different types of cookies below.',
1078
+ save: 'Save Preferences',
1079
+ acceptAll: 'Accept All',
1080
+ rejectAll: 'Reject All'
1081
+ },
1082
+ widget: {
1083
+ label: 'Cookie Settings'
1084
+ }
1085
+ },
1086
+ categories: {
1087
+ essential: {
1088
+ label: 'Essential',
1089
+ description: 'Required for the website to function properly. Cannot be disabled.'
1090
+ },
1091
+ functional: {
1092
+ label: 'Functional',
1093
+ description: 'Enable personalized features like language preferences and themes.'
1094
+ },
1095
+ analytics: {
1096
+ label: 'Analytics',
1097
+ description: 'Help us understand how visitors interact with our website.'
1098
+ },
1099
+ marketing: {
1100
+ label: 'Marketing',
1101
+ description: 'Used to deliver relevant advertisements and track campaign performance.'
1102
+ }
1103
+ }
1104
+ },
1105
+
1106
+ de: {
1107
+ labels: {
1108
+ banner: {
1109
+ title: 'Wir respektieren Ihre Privatsphäre',
1110
+ description: 'Wir verwenden Cookies, um Ihr Surferlebnis zu verbessern, personalisierte Inhalte bereitzustellen und unseren Datenverkehr zu analysieren. Mit einem Klick auf „Alle akzeptieren" stimmen Sie der Verwendung von Cookies zu.',
1111
+ acceptAll: 'Alle akzeptieren',
1112
+ rejectAll: 'Alle ablehnen',
1113
+ settings: 'Einstellungen'
1114
+ },
1115
+ modal: {
1116
+ title: 'Datenschutzeinstellungen',
1117
+ description: 'Verwalten Sie Ihre Cookie-Einstellungen. Sie können verschiedene Arten von Cookies unten aktivieren oder deaktivieren.',
1118
+ save: 'Einstellungen speichern',
1119
+ acceptAll: 'Alle akzeptieren',
1120
+ rejectAll: 'Alle ablehnen'
1121
+ },
1122
+ widget: {
1123
+ label: 'Cookie-Einstellungen'
1124
+ }
1125
+ },
1126
+ categories: {
1127
+ essential: {
1128
+ label: 'Notwendig',
1129
+ description: 'Erforderlich für die ordnungsgemäße Funktion der Website. Können nicht deaktiviert werden.'
1130
+ },
1131
+ functional: {
1132
+ label: 'Funktional',
1133
+ description: 'Ermöglichen personalisierte Funktionen wie Spracheinstellungen und Designs.'
1134
+ },
1135
+ analytics: {
1136
+ label: 'Analytisch',
1137
+ description: 'Helfen uns zu verstehen, wie Besucher mit unserer Website interagieren.'
1138
+ },
1139
+ marketing: {
1140
+ label: 'Marketing',
1141
+ description: 'Werden verwendet, um relevante Werbung anzuzeigen und die Kampagnenleistung zu messen.'
1142
+ }
1143
+ }
1144
+ },
1145
+
1146
+ es: {
1147
+ labels: {
1148
+ banner: {
1149
+ title: 'Valoramos tu privacidad',
1150
+ description: 'Utilizamos cookies para mejorar tu experiencia de navegación, ofrecer contenido personalizado y analizar nuestro tráfico. Al hacer clic en "Aceptar todo", consientes el uso de cookies.',
1151
+ acceptAll: 'Aceptar todo',
1152
+ rejectAll: 'Rechazar todo',
1153
+ settings: 'Configuración'
1154
+ },
1155
+ modal: {
1156
+ title: 'Configuración de privacidad',
1157
+ description: 'Gestiona tus preferencias de cookies. Puedes activar o desactivar diferentes tipos de cookies a continuación.',
1158
+ save: 'Guardar preferencias',
1159
+ acceptAll: 'Aceptar todo',
1160
+ rejectAll: 'Rechazar todo'
1161
+ },
1162
+ widget: {
1163
+ label: 'Configuración de cookies'
1164
+ }
1165
+ },
1166
+ categories: {
1167
+ essential: {
1168
+ label: 'Esenciales',
1169
+ description: 'Necesarias para el funcionamiento del sitio web. No se pueden desactivar.'
1170
+ },
1171
+ functional: {
1172
+ label: 'Funcionales',
1173
+ description: 'Permiten funciones personalizadas como preferencias de idioma y temas.'
1174
+ },
1175
+ analytics: {
1176
+ label: 'Analíticas',
1177
+ description: 'Nos ayudan a entender cómo los visitantes interactúan con nuestro sitio web.'
1178
+ },
1179
+ marketing: {
1180
+ label: 'Marketing',
1181
+ description: 'Se utilizan para mostrar anuncios relevantes y medir el rendimiento de las campañas.'
1182
+ }
1183
+ }
1184
+ },
1185
+
1186
+ fr: {
1187
+ labels: {
1188
+ banner: {
1189
+ title: 'Nous respectons votre vie privée',
1190
+ description: 'Nous utilisons des cookies pour améliorer votre expérience de navigation, proposer du contenu personnalisé et analyser notre trafic. En cliquant sur « Tout accepter », vous consentez à l\'utilisation de cookies.',
1191
+ acceptAll: 'Tout accepter',
1192
+ rejectAll: 'Tout refuser',
1193
+ settings: 'Paramètres'
1194
+ },
1195
+ modal: {
1196
+ title: 'Paramètres de confidentialité',
1197
+ description: 'Gérez vos préférences en matière de cookies. Vous pouvez activer ou désactiver différents types de cookies ci-dessous.',
1198
+ save: 'Enregistrer les préférences',
1199
+ acceptAll: 'Tout accepter',
1200
+ rejectAll: 'Tout refuser'
1201
+ },
1202
+ widget: {
1203
+ label: 'Paramètres des cookies'
1204
+ }
1205
+ },
1206
+ categories: {
1207
+ essential: {
1208
+ label: 'Essentiels',
1209
+ description: 'Nécessaires au bon fonctionnement du site. Ne peuvent pas être désactivés.'
1210
+ },
1211
+ functional: {
1212
+ label: 'Fonctionnels',
1213
+ description: 'Permettent des fonctionnalités personnalisées comme les préférences de langue et de thème.'
1214
+ },
1215
+ analytics: {
1216
+ label: 'Analytiques',
1217
+ description: 'Nous aident à comprendre comment les visiteurs interagissent avec notre site.'
1218
+ },
1219
+ marketing: {
1220
+ label: 'Marketing',
1221
+ description: 'Utilisés pour afficher des publicités pertinentes et mesurer les performances des campagnes.'
1222
+ }
1223
+ }
1224
+ },
1225
+
1226
+ it: {
1227
+ labels: {
1228
+ banner: {
1229
+ title: 'Rispettiamo la tua privacy',
1230
+ description: 'Utilizziamo i cookie per migliorare la tua esperienza di navigazione, fornire contenuti personalizzati e analizzare il nostro traffico. Cliccando su "Accetta tutto", acconsenti all\'uso dei cookie.',
1231
+ acceptAll: 'Accetta tutto',
1232
+ rejectAll: 'Rifiuta tutto',
1233
+ settings: 'Impostazioni'
1234
+ },
1235
+ modal: {
1236
+ title: 'Impostazioni privacy',
1237
+ description: 'Gestisci le tue preferenze sui cookie. Puoi attivare o disattivare diversi tipi di cookie qui sotto.',
1238
+ save: 'Salva preferenze',
1239
+ acceptAll: 'Accetta tutto',
1240
+ rejectAll: 'Rifiuta tutto'
1241
+ },
1242
+ widget: {
1243
+ label: 'Impostazioni cookie'
1244
+ }
1245
+ },
1246
+ categories: {
1247
+ essential: {
1248
+ label: 'Essenziali',
1249
+ description: 'Necessari per il corretto funzionamento del sito. Non possono essere disattivati.'
1250
+ },
1251
+ functional: {
1252
+ label: 'Funzionali',
1253
+ description: 'Abilitano funzionalità personalizzate come preferenze di lingua e tema.'
1254
+ },
1255
+ analytics: {
1256
+ label: 'Analitici',
1257
+ description: 'Ci aiutano a capire come i visitatori interagiscono con il nostro sito.'
1258
+ },
1259
+ marketing: {
1260
+ label: 'Marketing',
1261
+ description: 'Utilizzati per mostrare annunci pertinenti e misurare le prestazioni delle campagne.'
1262
+ }
1263
+ }
1264
+ },
1265
+
1266
+ pt: {
1267
+ labels: {
1268
+ banner: {
1269
+ title: 'Valorizamos sua privacidade',
1270
+ description: 'Usamos cookies para melhorar sua experiência de navegação, fornecer conteúdo personalizado e analisar nosso tráfego. Ao clicar em "Aceitar tudo", você consente com o uso de cookies.',
1271
+ acceptAll: 'Aceitar tudo',
1272
+ rejectAll: 'Rejeitar tudo',
1273
+ settings: 'Configurações'
1274
+ },
1275
+ modal: {
1276
+ title: 'Configurações de privacidade',
1277
+ description: 'Gerencie suas preferências de cookies. Você pode ativar ou desativar diferentes tipos de cookies abaixo.',
1278
+ save: 'Salvar preferências',
1279
+ acceptAll: 'Aceitar tudo',
1280
+ rejectAll: 'Rejeitar tudo'
1281
+ },
1282
+ widget: {
1283
+ label: 'Configurações de cookies'
1284
+ }
1285
+ },
1286
+ categories: {
1287
+ essential: {
1288
+ label: 'Essenciais',
1289
+ description: 'Necessários para o funcionamento do site. Não podem ser desativados.'
1290
+ },
1291
+ functional: {
1292
+ label: 'Funcionais',
1293
+ description: 'Permitem recursos personalizados como preferências de idioma e tema.'
1294
+ },
1295
+ analytics: {
1296
+ label: 'Analíticos',
1297
+ description: 'Nos ajudam a entender como os visitantes interagem com nosso site.'
1298
+ },
1299
+ marketing: {
1300
+ label: 'Marketing',
1301
+ description: 'Usados para exibir anúncios relevantes e medir o desempenho de campanhas.'
1302
+ }
1303
+ }
1304
+ },
1305
+
1306
+ nl: {
1307
+ labels: {
1308
+ banner: {
1309
+ title: 'Wij respecteren uw privacy',
1310
+ description: 'Wij gebruiken cookies om uw browse-ervaring te verbeteren, gepersonaliseerde inhoud aan te bieden en ons verkeer te analyseren. Door op "Alles accepteren" te klikken, stemt u in met het gebruik van cookies.',
1311
+ acceptAll: 'Alles accepteren',
1312
+ rejectAll: 'Alles weigeren',
1313
+ settings: 'Instellingen'
1314
+ },
1315
+ modal: {
1316
+ title: 'Privacy-instellingen',
1317
+ description: 'Beheer uw cookievoorkeuren. U kunt hieronder verschillende soorten cookies in- of uitschakelen.',
1318
+ save: 'Voorkeuren opslaan',
1319
+ acceptAll: 'Alles accepteren',
1320
+ rejectAll: 'Alles weigeren'
1321
+ },
1322
+ widget: {
1323
+ label: 'Cookie-instellingen'
1324
+ }
1325
+ },
1326
+ categories: {
1327
+ essential: {
1328
+ label: 'Essentieel',
1329
+ description: 'Noodzakelijk voor de goede werking van de website. Kunnen niet worden uitgeschakeld.'
1330
+ },
1331
+ functional: {
1332
+ label: 'Functioneel',
1333
+ description: 'Maken gepersonaliseerde functies mogelijk zoals taal- en themavoorkeuren.'
1334
+ },
1335
+ analytics: {
1336
+ label: 'Analytisch',
1337
+ description: 'Helpen ons te begrijpen hoe bezoekers onze website gebruiken.'
1338
+ },
1339
+ marketing: {
1340
+ label: 'Marketing',
1341
+ description: 'Worden gebruikt om relevante advertenties te tonen en campagneprestaties te meten.'
1342
+ }
1343
+ }
1344
+ },
1345
+
1346
+ pl: {
1347
+ labels: {
1348
+ banner: {
1349
+ title: 'Szanujemy Twoją prywatność',
1350
+ description: 'Używamy plików cookie, aby poprawić Twoje wrażenia z przeglądania, dostarczać spersonalizowane treści i analizować nasz ruch. Klikając „Zaakceptuj wszystko", wyrażasz zgodę na używanie plików cookie.',
1351
+ acceptAll: 'Zaakceptuj wszystko',
1352
+ rejectAll: 'Odrzuć wszystko',
1353
+ settings: 'Ustawienia'
1354
+ },
1355
+ modal: {
1356
+ title: 'Ustawienia prywatności',
1357
+ description: 'Zarządzaj swoimi preferencjami dotyczącymi plików cookie. Możesz włączyć lub wyłączyć różne typy plików cookie poniżej.',
1358
+ save: 'Zapisz preferencje',
1359
+ acceptAll: 'Zaakceptuj wszystko',
1360
+ rejectAll: 'Odrzuć wszystko'
1361
+ },
1362
+ widget: {
1363
+ label: 'Ustawienia plików cookie'
1364
+ }
1365
+ },
1366
+ categories: {
1367
+ essential: {
1368
+ label: 'Niezbędne',
1369
+ description: 'Wymagane do prawidłowego działania strony. Nie można ich wyłączyć.'
1370
+ },
1371
+ functional: {
1372
+ label: 'Funkcjonalne',
1373
+ description: 'Umożliwiają spersonalizowane funkcje, takie jak preferencje językowe i motywy.'
1374
+ },
1375
+ analytics: {
1376
+ label: 'Analityczne',
1377
+ description: 'Pomagają nam zrozumieć, jak odwiedzający korzystają z naszej strony.'
1378
+ },
1379
+ marketing: {
1380
+ label: 'Marketingowe',
1381
+ description: 'Służą do wyświetlania odpowiednich reklam i mierzenia skuteczności kampanii.'
1382
+ }
1383
+ }
1384
+ },
1385
+
1386
+ uk: {
1387
+ labels: {
1388
+ banner: {
1389
+ title: 'Ми цінуємо вашу конфіденційність',
1390
+ description: 'Ми використовуємо файли cookie для покращення вашого досвіду перегляду, надання персоналізованого контенту та аналізу нашого трафіку. Натискаючи «Прийняти все», ви погоджуєтесь на використання файлів cookie.',
1391
+ acceptAll: 'Прийняти все',
1392
+ rejectAll: 'Відхилити все',
1393
+ settings: 'Налаштування'
1394
+ },
1395
+ modal: {
1396
+ title: 'Налаштування конфіденційності',
1397
+ description: 'Керуйте своїми налаштуваннями файлів cookie. Ви можете ввімкнути або вимкнути різні типи файлів cookie нижче.',
1398
+ save: 'Зберегти налаштування',
1399
+ acceptAll: 'Прийняти все',
1400
+ rejectAll: 'Відхилити все'
1401
+ },
1402
+ widget: {
1403
+ label: 'Налаштування cookie'
1404
+ }
1405
+ },
1406
+ categories: {
1407
+ essential: {
1408
+ label: 'Необхідні',
1409
+ description: 'Потрібні для правильної роботи сайту. Не можуть бути вимкнені.'
1410
+ },
1411
+ functional: {
1412
+ label: 'Функціональні',
1413
+ description: 'Дозволяють персоналізовані функції, такі як мовні налаштування та теми.'
1414
+ },
1415
+ analytics: {
1416
+ label: 'Аналітичні',
1417
+ description: 'Допомагають нам зрозуміти, як відвідувачі взаємодіють з нашим сайтом.'
1418
+ },
1419
+ marketing: {
1420
+ label: 'Маркетингові',
1421
+ description: 'Використовуються для показу релевантної реклами та вимірювання ефективності кампаній.'
1422
+ }
1423
+ }
1424
+ },
1425
+
1426
+ ru: {
1427
+ labels: {
1428
+ banner: {
1429
+ title: 'Мы ценим вашу конфиденциальность',
1430
+ description: 'Мы используем файлы cookie для улучшения вашего опыта просмотра, предоставления персонализированного контента и анализа нашего трафика. Нажимая «Принять все», вы соглашаетесь на использование файлов cookie.',
1431
+ acceptAll: 'Принять все',
1432
+ rejectAll: 'Отклонить все',
1433
+ settings: 'Настройки'
1434
+ },
1435
+ modal: {
1436
+ title: 'Настройки конфиденциальности',
1437
+ description: 'Управляйте своими настройками файлов cookie. Вы можете включить или отключить различные типы файлов cookie ниже.',
1438
+ save: 'Сохранить настройки',
1439
+ acceptAll: 'Принять все',
1440
+ rejectAll: 'Отклонить все'
1441
+ },
1442
+ widget: {
1443
+ label: 'Настройки cookie'
1444
+ }
1445
+ },
1446
+ categories: {
1447
+ essential: {
1448
+ label: 'Необходимые',
1449
+ description: 'Требуются для правильной работы сайта. Не могут быть отключены.'
1450
+ },
1451
+ functional: {
1452
+ label: 'Функциональные',
1453
+ description: 'Позволяют использовать персонализированные функции, такие как языковые настройки и темы.'
1454
+ },
1455
+ analytics: {
1456
+ label: 'Аналитические',
1457
+ description: 'Помогают нам понять, как посетители взаимодействуют с нашим сайтом.'
1458
+ },
1459
+ marketing: {
1460
+ label: 'Маркетинговые',
1461
+ description: 'Используются для показа релевантной рекламы и измерения эффективности кампаний.'
1462
+ }
1463
+ }
1464
+ },
1465
+
1466
+ ja: {
1467
+ labels: {
1468
+ banner: {
1469
+ title: 'プライバシーを尊重します',
1470
+ description: '当サイトでは、ブラウジング体験の向上、パーソナライズされたコンテンツの提供、トラフィックの分析のためにCookieを使用しています。「すべて同意」をクリックすると、Cookieの使用に同意したことになります。',
1471
+ acceptAll: 'すべて同意',
1472
+ rejectAll: 'すべて拒否',
1473
+ settings: '設定'
1474
+ },
1475
+ modal: {
1476
+ title: 'プライバシー設定',
1477
+ description: 'Cookieの設定を管理できます。以下で各種Cookieを有効または無効にできます。',
1478
+ save: '設定を保存',
1479
+ acceptAll: 'すべて同意',
1480
+ rejectAll: 'すべて拒否'
1481
+ },
1482
+ widget: {
1483
+ label: 'Cookie設定'
1484
+ }
1485
+ },
1486
+ categories: {
1487
+ essential: {
1488
+ label: '必須',
1489
+ description: 'サイトの正常な動作に必要です。無効にすることはできません。'
1490
+ },
1491
+ functional: {
1492
+ label: '機能性',
1493
+ description: '言語設定やテーマなどのパーソナライズ機能を有効にします。'
1494
+ },
1495
+ analytics: {
1496
+ label: '分析',
1497
+ description: '訪問者がサイトをどのように利用しているかを理解するのに役立ちます。'
1498
+ },
1499
+ marketing: {
1500
+ label: 'マーケティング',
1501
+ description: '関連性の高い広告を表示し、キャンペーンの効果を測定するために使用されます。'
1502
+ }
1503
+ }
1504
+ },
1505
+
1506
+ zh: {
1507
+ labels: {
1508
+ banner: {
1509
+ title: '我们重视您的隐私',
1510
+ description: '我们使用Cookie来改善您的浏览体验、提供个性化内容并分析我们的流量。点击"全部接受"即表示您同意我们使用Cookie。',
1511
+ acceptAll: '全部接受',
1512
+ rejectAll: '全部拒绝',
1513
+ settings: '设置'
1514
+ },
1515
+ modal: {
1516
+ title: '隐私设置',
1517
+ description: '管理您的Cookie偏好设置。您可以在下方启用或禁用不同类型的Cookie。',
1518
+ save: '保存设置',
1519
+ acceptAll: '全部接受',
1520
+ rejectAll: '全部拒绝'
1521
+ },
1522
+ widget: {
1523
+ label: 'Cookie设置'
1524
+ }
1525
+ },
1526
+ categories: {
1527
+ essential: {
1528
+ label: '必要',
1529
+ description: '网站正常运行所必需的。无法禁用。'
1530
+ },
1531
+ functional: {
1532
+ label: '功能性',
1533
+ description: '启用个性化功能,如语言偏好和主题设置。'
1534
+ },
1535
+ analytics: {
1536
+ label: '分析',
1537
+ description: '帮助我们了解访问者如何与网站互动。'
1538
+ },
1539
+ marketing: {
1540
+ label: '营销',
1541
+ description: '用于展示相关广告并衡量营销活动效果。'
1542
+ }
1543
+ }
1544
+ }
1545
+ };
1546
+
1547
+ /**
1548
+ * Detect language from various sources
1549
+ * Priority: config.lang > <html lang=""> > navigator.language > 'en'
1550
+ */
1551
+ function detectLanguage(configLang) {
1552
+ // 1. Explicit config
1553
+ if (configLang && configLang !== 'auto') {
1554
+ return normalizeLanguage(configLang);
1555
+ }
1556
+
1557
+ // 2. HTML lang attribute
1558
+ const htmlLang = document.documentElement.lang;
1559
+ if (htmlLang) {
1560
+ const normalized = normalizeLanguage(htmlLang);
1561
+ if (translations[normalized]) {
1562
+ return normalized;
1563
+ }
1564
+ }
1565
+
1566
+ // 3. Browser language
1567
+ if (typeof navigator !== 'undefined' && navigator.language) {
1568
+ const normalized = normalizeLanguage(navigator.language);
1569
+ if (translations[normalized]) {
1570
+ return normalized;
1571
+ }
1572
+ }
1573
+
1574
+ // 4. Default to English
1575
+ return 'en';
1576
+ }
1577
+
1578
+ /**
1579
+ * Normalize language code (e.g., 'en-US' -> 'en', 'es_MX' -> 'es')
1580
+ */
1581
+ function normalizeLanguage(lang) {
1582
+ if (!lang) return 'en';
1583
+
1584
+ // ISO 639-1 language codes are always 2 characters
1585
+ const base = lang.slice(0, 2).toLowerCase();
1586
+
1587
+ // Check if we support it
1588
+ if (translations[base]) {
1589
+ return base;
1590
+ }
1591
+
1592
+ return 'en';
1593
+ }
1594
+
1595
+ /**
1596
+ * Get translation for a language
1597
+ */
1598
+ function getTranslation(lang) {
1599
+ return translations[lang] || translations.en;
1600
+ }
1601
+
1602
+ /**
1603
+ * Default configuration values
1604
+ */
1605
+
1606
+
1607
+ const DEFAULTS = {
1608
+ // Language: 'auto' | 'en' | 'de' | 'es' | 'fr' | 'it' | 'pt' | 'nl' | 'pl' | 'uk' | 'ru' | 'ja' | 'zh'
1609
+ lang: 'auto',
1610
+
1611
+ // UI positioning
1612
+ position: 'bottom', // 'bottom' | 'bottom-left' | 'bottom-right' | 'top'
1613
+
1614
+ // Theming
1615
+ theme: 'auto', // 'light' | 'dark' | 'auto'
1616
+ accentColor: '#0071e3',
1617
+
1618
+ // Categories
1619
+ categories: DEFAULT_CATEGORIES,
1620
+
1621
+ // UI Labels
1622
+ labels: {
1623
+ banner: {
1624
+ title: 'We value your privacy',
1625
+ description: 'We use cookies to enhance your browsing experience, serve personalized content, and analyze our traffic. By clicking "Accept All", you consent to our use of cookies.',
1626
+ acceptAll: 'Accept All',
1627
+ rejectAll: 'Reject All',
1628
+ settings: 'Settings'
1629
+ },
1630
+ modal: {
1631
+ title: 'Privacy Settings',
1632
+ description: 'Manage your cookie preferences. You can enable or disable different types of cookies below.',
1633
+ save: 'Save Preferences',
1634
+ acceptAll: 'Accept All',
1635
+ rejectAll: 'Reject All'
1636
+ },
1637
+ widget: {
1638
+ label: 'Cookie Settings'
1639
+ }
1640
+ },
1641
+
1642
+ // Behavior
1643
+ autoInit: true,
1644
+ showWidget: true,
1645
+ expiration: 365,
1646
+
1647
+ // Do Not Track / Global Privacy Control
1648
+ // respectDNT: true = respect DNT/GPC signals
1649
+ // dntBehavior: 'reject' | 'preselect' | 'ignore'
1650
+ // - 'reject': auto-reject non-essential, don't show banner
1651
+ // - 'preselect': show banner with non-essential unchecked (same as normal)
1652
+ // - 'ignore': ignore DNT completely
1653
+ respectDNT: true,
1654
+ dntBehavior: 'reject',
1655
+
1656
+ // Custom styles to inject into Shadow DOM
1657
+ customStyles: '',
1658
+
1659
+ // Vendor consent mode integrations (optional)
1660
+ consentModeGoogle: false,
1661
+ consentModeMicrosoft: false,
1662
+
1663
+ // Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
1664
+ mode: 'safe',
1665
+
1666
+ // Custom domains to block (in addition to mode-based blocking)
1667
+ blockedDomains: [], // days
1668
+
1669
+ // Links
1670
+ policyUrl: null,
1671
+ imprintUrl: null,
1672
+
1673
+ // Callbacks
1674
+ callbacks: {
1675
+ onAccept: null,
1676
+ onReject: null,
1677
+ onChange: null,
1678
+ onReady: null
1679
+ }
1680
+ };
1681
+
1682
+ /**
1683
+ * Merge user config with defaults (deep merge)
1684
+ */
1685
+ function mergeConfig(userConfig) {
1686
+ const config = { ...DEFAULTS };
1687
+
1688
+ if (!userConfig) {
1689
+ userConfig = {};
1690
+ }
1691
+
1692
+ // Simple properties
1693
+ const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior', 'consentModeGoogle', 'consentModeMicrosoft'];
1694
+ for (const key of simpleKeys) {
1695
+ if (userConfig[key] !== undefined) {
1696
+ config[key] = userConfig[key];
1697
+ }
1698
+ }
1699
+
1700
+ // Detect language and get translations
1701
+ const detectedLang = detectLanguage(config.lang);
1702
+ config.lang = detectedLang;
1703
+ const translation = getTranslation(detectedLang);
1704
+
1705
+ // Deep merge labels (translation < user config)
1706
+ const translationLabels = translation.labels || {};
1707
+ const userLabels = userConfig.labels || {};
1708
+ config.labels = {
1709
+ banner: {
1710
+ ...DEFAULTS.labels.banner,
1711
+ ...translationLabels.banner,
1712
+ ...userLabels.banner
1713
+ },
1714
+ modal: {
1715
+ ...DEFAULTS.labels.modal,
1716
+ ...translationLabels.modal,
1717
+ ...userLabels.modal
1718
+ },
1719
+ widget: {
1720
+ ...DEFAULTS.labels.widget,
1721
+ ...translationLabels.widget,
1722
+ ...userLabels.widget
1723
+ }
1724
+ };
1725
+
1726
+ // Deep merge categories (translation < user config)
1727
+ const translationCategories = translation.categories || {};
1728
+ const userCategories = userConfig.categories || {};
1729
+ config.categories = { ...DEFAULTS.categories };
1730
+ for (const key of Object.keys(DEFAULTS.categories)) {
1731
+ config.categories[key] = {
1732
+ ...DEFAULTS.categories[key],
1733
+ ...translationCategories[key],
1734
+ ...userCategories[key]
1735
+ };
1736
+ }
1737
+
1738
+ // Merge callbacks
1739
+ if (userConfig.callbacks) {
1740
+ config.callbacks = { ...DEFAULTS.callbacks, ...userConfig.callbacks };
1741
+ }
1742
+
1743
+ // Patterns (for pattern matcher)
1744
+ if (userConfig.patterns) {
1745
+ config.patterns = userConfig.patterns;
1746
+ }
1747
+
1748
+ return config;
1749
+ }
1750
+
1751
+ /**
1752
+ * Configuration Parser - Reads config from various sources
1753
+ */
1754
+
1755
+
1756
+ /**
1757
+ * Update configuration at runtime
1758
+ */
1759
+ let currentConfig$1 = null;
1760
+
1761
+ function setConfig(config) {
1762
+ currentConfig$1 = mergeConfig(config);
1763
+ return currentConfig$1;
1764
+ }
1765
+
1766
+ /**
1767
+ * Consent Store - Manages consent state persistence
1768
+ */
1769
+
1770
+
1771
+ const COOKIE_NAME = 'zest_consent';
1772
+ const CONSENT_VERSION = '1.0';
1773
+
1774
+ /**
1775
+ * Return the Secure flag fragment when running over HTTPS, empty otherwise.
1776
+ * On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
1777
+ */
1778
+ function secureAttribute() {
1779
+ try {
1780
+ return typeof location !== 'undefined' && location.protocol === 'https:'
1781
+ ? '; Secure'
1782
+ : '';
1783
+ } catch (_) {
1784
+ return '';
1785
+ }
1786
+ }
1787
+
1788
+ // Current consent state
1789
+ let consent = null;
1790
+
1791
+ /**
1792
+ * Get the original cookie setter (bypasses interception)
1793
+ */
1794
+ function setRawCookie(value) {
1795
+ const descriptor = getOriginalCookieDescriptor();
1796
+ if (descriptor?.set) {
1797
+ descriptor.set.call(document, value);
1798
+ } else {
1799
+ // Fallback if interceptor not initialized yet
1800
+ document.cookie = value;
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * Get the original cookie getter
1806
+ */
1807
+ function getRawCookie() {
1808
+ const descriptor = getOriginalCookieDescriptor();
1809
+ if (descriptor?.get) {
1810
+ return descriptor.get.call(document);
1811
+ }
1812
+ return document.cookie;
1813
+ }
1814
+
1815
+ /**
1816
+ * Load consent from cookie.
1817
+ *
1818
+ * The parsed cookie is validated against the expected schema via
1819
+ * sanitizeConsentPayload — only known category keys with boolean values
1820
+ * survive, so a tampered cookie can't inject prototype-polluting props
1821
+ * or unexpected category shapes.
1822
+ */
1823
+ function loadConsent() {
1824
+ try {
1825
+ const cookies = getRawCookie();
1826
+ const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
1827
+
1828
+ if (match) {
1829
+ const raw = JSON.parse(decodeURIComponent(match[1]));
1830
+ const clean = sanitizeConsentPayload(raw, getCategoryIds());
1831
+ if (clean && clean.categories) {
1832
+ consent = { ...getDefaultConsent(), ...clean.categories };
1833
+ return { ...consent };
1834
+ }
1835
+ }
1836
+ } catch (e) {
1837
+ // Invalid or missing cookie
1838
+ }
1839
+
1840
+ consent = getDefaultConsent();
1841
+ return { ...consent };
1842
+ }
1843
+
1844
+ /**
1845
+ * Save consent to cookie
1846
+ */
1847
+ function saveConsent(expirationDays = 365) {
1848
+ if (!consent) {
1849
+ consent = getDefaultConsent();
1850
+ }
1851
+
1852
+ const data = {
1853
+ version: CONSENT_VERSION,
1854
+ timestamp: Date.now(),
1855
+ categories: consent
1856
+ };
1857
+
1858
+ const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
1859
+ const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
1860
+
1861
+ setRawCookie(cookieValue);
1862
+ }
1863
+
1864
+ /**
1865
+ * Get current consent state
1866
+ */
1867
+ function getConsent() {
1868
+ if (!consent) {
1869
+ consent = loadConsent();
1870
+ }
1871
+ return { ...consent };
1872
+ }
1873
+
1874
+ /**
1875
+ * Update consent state
1876
+ */
1877
+ function updateConsent$1(newConsent, expirationDays = 365) {
1878
+ const previous = consent ? { ...consent } : getDefaultConsent();
1879
+
1880
+ consent = {
1881
+ essential: true, // Always true
1882
+ functional: !!newConsent.functional,
1883
+ analytics: !!newConsent.analytics,
1884
+ marketing: !!newConsent.marketing
1885
+ };
1886
+
1887
+ saveConsent(expirationDays);
1888
+
1889
+ return { current: { ...consent }, previous };
1890
+ }
1891
+
1892
+ /**
1893
+ * Check if specific category is allowed
1894
+ */
1895
+ function hasConsent(category) {
1896
+ if (!consent) {
1897
+ consent = loadConsent();
1898
+ }
1899
+ return consent[category] === true;
1900
+ }
1901
+
1902
+ /**
1903
+ * Accept all categories
1904
+ */
1905
+ function acceptAll$1(expirationDays = 365) {
1906
+ return updateConsent$1({
1907
+ functional: true,
1908
+ analytics: true,
1909
+ marketing: true
1910
+ }, expirationDays);
1911
+ }
1912
+
1913
+ /**
1914
+ * Reject all (except essential)
1915
+ */
1916
+ function rejectAll$1(expirationDays = 365) {
1917
+ return updateConsent$1({
1918
+ functional: false,
1919
+ analytics: false,
1920
+ marketing: false
1921
+ }, expirationDays);
1922
+ }
1923
+
1924
+ /**
1925
+ * Reset consent (clear cookie)
1926
+ */
1927
+ function resetConsent() {
1928
+ setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
1929
+ consent = null;
1930
+ }
1931
+
1932
+ /**
1933
+ * Check if consent has been given (any decision made)
1934
+ */
1935
+ function hasConsentDecision() {
1936
+ try {
1937
+ const cookies = getRawCookie();
1938
+ return cookies.includes(COOKIE_NAME);
1939
+ } catch (e) {
1940
+ return false;
1941
+ }
1942
+ }
1943
+
1944
+ /**
1945
+ * Get consent proof for compliance
1946
+ */
1947
+ function getConsentProof() {
1948
+ try {
1949
+ const cookies = getRawCookie();
1950
+ const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
1951
+
1952
+ if (match) {
1953
+ const raw = JSON.parse(decodeURIComponent(match[1]));
1954
+ return sanitizeConsentPayload(raw, getCategoryIds());
1955
+ }
1956
+ } catch (e) {
1957
+ // Invalid cookie
1958
+ }
1959
+
1960
+ return null;
1961
+ }
1962
+
1963
+ /**
1964
+ * Events - Custom event dispatching for consent changes
1965
+ */
1966
+
1967
+ // Event names
1968
+ const EVENTS = {
1969
+ READY: 'zest:ready',
1970
+ CONSENT: 'zest:consent',
1971
+ REJECT: 'zest:reject',
1972
+ CHANGE: 'zest:change',
1973
+ SHOW: 'zest:show',
1974
+ HIDE: 'zest:hide'
1975
+ };
1976
+
1977
+ /**
1978
+ * Dispatch a custom event
1979
+ */
1980
+ function emit(eventName, detail = {}) {
1981
+ const event = new CustomEvent(eventName, {
1982
+ detail,
1983
+ bubbles: true,
1984
+ cancelable: true
1985
+ });
1986
+
1987
+ document.dispatchEvent(event);
1988
+ return event;
1989
+ }
1990
+
1991
+ /**
1992
+ * Emit ready event
1993
+ */
1994
+ function emitReady(consent) {
1995
+ return emit(EVENTS.READY, { consent });
1996
+ }
1997
+
1998
+ /**
1999
+ * Emit consent event (user accepted)
2000
+ */
2001
+ function emitConsent(consent, previous) {
2002
+ return emit(EVENTS.CONSENT, { consent, previous });
2003
+ }
2004
+
2005
+ /**
2006
+ * Emit reject event (user rejected all)
2007
+ */
2008
+ function emitReject(consent) {
2009
+ return emit(EVENTS.REJECT, { consent });
2010
+ }
2011
+
2012
+ /**
2013
+ * Emit change event (any consent change)
2014
+ */
2015
+ function emitChange(consent, previous) {
2016
+ return emit(EVENTS.CHANGE, { consent, previous });
2017
+ }
2018
+
2019
+ /**
2020
+ * Subscribe to an event
2021
+ */
2022
+ function on(eventName, callback) {
2023
+ document.addEventListener(eventName, callback);
2024
+ return () => document.removeEventListener(eventName, callback);
2025
+ }
2026
+
2027
+ /**
2028
+ * Subscribe to an event once
2029
+ */
2030
+ function once(eventName, callback) {
2031
+ document.addEventListener(eventName, callback, { once: true });
2032
+ }
2033
+
2034
+ /**
2035
+ * Core lifecycle - UI-agnostic initialization and consent actions.
2036
+ *
2037
+ * This module contains everything the main entry (with UI) and the
2038
+ * headless entry (no UI) share: interceptor setup, consent load/save,
2039
+ * replay, DNT handling, and the events/callbacks fan-out. It intentionally
2040
+ * does NOT import anything from `./ui/*` so tree-shakers can drop the UI
2041
+ * bundle entirely when only the headless API is used.
2042
+ */
2043
+
2044
+
2045
+ let initialized = false;
2046
+ let currentConfig = null;
2047
+
2048
+ function checkConsent(category) {
2049
+ return hasConsent(category);
2050
+ }
2051
+
2052
+ function replayAll(categories) {
2053
+ replayCookies(categories);
2054
+ replayStorage(categories);
2055
+ replayScripts(categories);
2056
+ }
2057
+
2058
+ /**
2059
+ * Run the non-UI half of init. Returns a snapshot the caller (UI or
2060
+ * headless) can use to decide what to do next.
2061
+ */
2062
+ function coreInit(userConfig = {}) {
2063
+ if (initialized) {
2064
+ return {
2065
+ alreadyInitialized: true,
2066
+ config: currentConfig,
2067
+ consent: loadConsent(),
2068
+ hasDecision: hasConsentDecision(),
2069
+ dntApplied: false
2070
+ };
2071
+ }
2072
+
2073
+ currentConfig = setConfig(userConfig);
2074
+
2075
+ // Push default-denied state to vendor consent mode APIs BEFORE any
2076
+ // third-party script has a chance to fire.
2077
+ applyConsentSignals(
2078
+ { functional: false, analytics: false, marketing: false },
2079
+ currentConfig,
2080
+ true
2081
+ );
2082
+
2083
+ if (currentConfig.patterns) {
2084
+ setPatterns(currentConfig.patterns);
2085
+ }
2086
+
2087
+ setConsentChecker$2(checkConsent);
2088
+ setConsentChecker$1(checkConsent);
2089
+ setConsentChecker(checkConsent);
2090
+
2091
+ interceptCookies();
2092
+ interceptStorage();
2093
+ startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
2094
+
2095
+ const consent = loadConsent();
2096
+ initialized = true;
2097
+
2098
+ if (hasConsentDecision()) {
2099
+ applyConsentSignals(consent, currentConfig, false);
2100
+ }
2101
+
2102
+ // DNT / GPC handling — if the user signalled opt-out at the browser
2103
+ // level and the site opts to respect it, auto-reject before the UI
2104
+ // layer ever runs.
2105
+ const dntEnabled = isDoNotTrackEnabled();
2106
+ let dntApplied = false;
2107
+
2108
+ if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
2109
+ if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
2110
+ const result = rejectAll$1(currentConfig.expiration);
2111
+ dntApplied = true;
2112
+ applyConsentSignals(result.current, currentConfig, false);
2113
+ emitReject(result.current);
2114
+ emitChange(result.current, result.previous);
2115
+ safeInvoke(currentConfig.callbacks?.onReject);
2116
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
2117
+ }
2118
+ }
2119
+
2120
+ emitReady(consent);
2121
+ safeInvoke(currentConfig.callbacks?.onReady, consent);
2122
+
2123
+ return {
2124
+ alreadyInitialized: false,
2125
+ config: currentConfig,
2126
+ consent,
2127
+ hasDecision: hasConsentDecision(),
2128
+ dntApplied
2129
+ };
2130
+ }
2131
+
2132
+ /**
2133
+ * Accept all categories, replay queued items, fire events + callbacks.
2134
+ * Returns { current, previous } or null if not yet initialized.
2135
+ */
2136
+ function coreAcceptAll() {
2137
+ if (!initialized) return null;
2138
+ const result = acceptAll$1(currentConfig.expiration);
2139
+ applyConsentSignals(result.current, currentConfig, false);
2140
+ replayAll(getCategoryIds());
2141
+ emitConsent(result.current, result.previous);
2142
+ emitChange(result.current, result.previous);
2143
+ safeInvoke(currentConfig.callbacks?.onAccept, result.current);
2144
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
2145
+ return result;
2146
+ }
2147
+
2148
+ /**
2149
+ * Reject all non-essential categories, fire events + callbacks.
2150
+ */
2151
+ function coreRejectAll() {
2152
+ if (!initialized) return null;
2153
+ const result = rejectAll$1(currentConfig.expiration);
2154
+ applyConsentSignals(result.current, currentConfig, false);
2155
+ emitReject(result.current);
2156
+ emitChange(result.current, result.previous);
2157
+ safeInvoke(currentConfig.callbacks?.onReject);
2158
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
2159
+ return result;
2160
+ }
2161
+
2162
+ /**
2163
+ * Save custom selections and replay only the newly-allowed categories.
2164
+ */
2165
+ function coreUpdateConsent(selections) {
2166
+ if (!initialized) return null;
2167
+ const result = updateConsent$1(selections, currentConfig.expiration);
2168
+ applyConsentSignals(result.current, currentConfig, false);
2169
+
2170
+ const newlyAllowed = Object.keys(result.current).filter(
2171
+ (cat) => result.current[cat] && !result.previous[cat]
2172
+ );
2173
+ if (newlyAllowed.length > 0) {
2174
+ replayAll(newlyAllowed);
2175
+ }
2176
+
2177
+ const hasNonEssential = Object.entries(selections || {}).some(
2178
+ ([cat, val]) => cat !== 'essential' && val
2179
+ );
2180
+ if (hasNonEssential) {
2181
+ emitConsent(result.current, result.previous);
2182
+ } else {
2183
+ emitReject(result.current);
2184
+ }
2185
+ emitChange(result.current, result.previous);
2186
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
2187
+ return result;
2188
+ }
2189
+
2190
+ /**
2191
+ * Clear the consent cookie. The caller is responsible for any UI reset.
2192
+ */
2193
+ function coreReset() {
2194
+ resetConsent();
2195
+ }
2196
+
2197
+ function isInitialized() {
2198
+ return initialized;
2199
+ }
2200
+
2201
+ function getActiveConfig() {
2202
+ return currentConfig;
2203
+ }
2204
+
2205
+ /**
2206
+ * Zest Headless - consent logic only, zero UI.
2207
+ *
2208
+ * Use this entry when you want to bring your own banner / modal / settings
2209
+ * markup and style it with your own CSS. No Shadow DOM is mounted, no
2210
+ * inline stylesheet is injected, and nothing is attached to `window`.
2211
+ *
2212
+ * Everything you need to wire a custom UI is here:
2213
+ *
2214
+ * import Zest from '@freshjuice/zest/headless';
2215
+ *
2216
+ * Zest.init({ mode: 'safe', respectDNT: true });
2217
+ *
2218
+ * if (!Zest.hasConsentDecision()) {
2219
+ * myBanner.show();
2220
+ * }
2221
+ *
2222
+ * myAcceptBtn.addEventListener('click', () => Zest.acceptAll());
2223
+ * myRejectBtn.addEventListener('click', () => Zest.rejectAll());
2224
+ * mySaveBtn.addEventListener('click', () => {
2225
+ * Zest.updateConsent({ analytics: true, marketing: false });
2226
+ * });
2227
+ *
2228
+ * Zest.on(Zest.EVENTS.CHANGE, (e) => console.log(e.detail.consent));
2229
+ *
2230
+ * The headless build does NOT auto-initialize. You must call `init()`
2231
+ * yourself, so you control exactly when interceptors come online.
2232
+ */
2233
+
2234
+
2235
+ function init(userConfig = {}) {
2236
+ const snapshot = coreInit(userConfig);
2237
+ // Headless returns the snapshot so callers can decide whether to
2238
+ // render their banner / settings UI based on hasDecision / dntApplied.
2239
+ return snapshot;
2240
+ }
2241
+
2242
+ function acceptAll() {
2243
+ if (!isInitialized()) {
2244
+ console.warn('[Zest] Not initialized. Call Zest.init() first.');
2245
+ return null;
2246
+ }
2247
+ return coreAcceptAll();
2248
+ }
2249
+
2250
+ function rejectAll() {
2251
+ if (!isInitialized()) {
2252
+ console.warn('[Zest] Not initialized. Call Zest.init() first.');
2253
+ return null;
2254
+ }
2255
+ return coreRejectAll();
2256
+ }
2257
+
2258
+ function updateConsent(selections) {
2259
+ if (!isInitialized()) {
2260
+ console.warn('[Zest] Not initialized. Call Zest.init() first.');
2261
+ return null;
2262
+ }
2263
+ return coreUpdateConsent(selections);
2264
+ }
2265
+
2266
+ function reset() {
2267
+ coreReset();
2268
+ }
2269
+
2270
+ const Zest = {
2271
+ init,
2272
+
2273
+ // Consent state
2274
+ getConsent,
2275
+ hasConsent,
2276
+ hasConsentDecision,
2277
+ getConsentProof,
2278
+
2279
+ // Actions
2280
+ acceptAll,
2281
+ rejectAll,
2282
+ updateConsent,
2283
+ reset,
2284
+
2285
+ // DNT introspection
2286
+ isDoNotTrackEnabled,
2287
+ getDNTDetails,
2288
+
2289
+ // Events
2290
+ on,
2291
+ once,
2292
+ EVENTS,
2293
+
2294
+ // Config introspection
2295
+ getConfig: getActiveConfig
2296
+ };
2297
+
2298
+ export { EVENTS, acceptAll, Zest as default, getActiveConfig as getConfig, getConsent, getConsentProof, getDNTDetails, hasConsent, hasConsentDecision, init, isDoNotTrackEnabled, on, once, rejectAll, reset, updateConsent };
2299
+ //# sourceMappingURL=zest.headless.esm.js.map