@freshjuice/zest 2.2.0 → 2.3.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 (56) hide show
  1. package/dist/zest.d.ts +7 -0
  2. package/dist/zest.de.js +658 -50
  3. package/dist/zest.de.js.map +1 -1
  4. package/dist/zest.de.min.js +1 -1
  5. package/dist/zest.en.js +658 -50
  6. package/dist/zest.en.js.map +1 -1
  7. package/dist/zest.en.min.js +1 -1
  8. package/dist/zest.es.js +658 -50
  9. package/dist/zest.es.js.map +1 -1
  10. package/dist/zest.es.min.js +1 -1
  11. package/dist/zest.esm.js +658 -50
  12. package/dist/zest.esm.js.map +1 -1
  13. package/dist/zest.esm.min.js +1 -1
  14. package/dist/zest.fr.js +658 -50
  15. package/dist/zest.fr.js.map +1 -1
  16. package/dist/zest.fr.min.js +1 -1
  17. package/dist/zest.headless.d.ts +7 -0
  18. package/dist/zest.headless.esm.js +612 -32
  19. package/dist/zest.headless.esm.js.map +1 -1
  20. package/dist/zest.headless.esm.min.js +1 -1
  21. package/dist/zest.it.js +658 -50
  22. package/dist/zest.it.js.map +1 -1
  23. package/dist/zest.it.min.js +1 -1
  24. package/dist/zest.ja.js +658 -50
  25. package/dist/zest.ja.js.map +1 -1
  26. package/dist/zest.ja.min.js +1 -1
  27. package/dist/zest.js +658 -50
  28. package/dist/zest.js.map +1 -1
  29. package/dist/zest.min.js +1 -1
  30. package/dist/zest.nl.js +658 -50
  31. package/dist/zest.nl.js.map +1 -1
  32. package/dist/zest.nl.min.js +1 -1
  33. package/dist/zest.pl.js +658 -50
  34. package/dist/zest.pl.js.map +1 -1
  35. package/dist/zest.pl.min.js +1 -1
  36. package/dist/zest.pt.js +658 -50
  37. package/dist/zest.pt.js.map +1 -1
  38. package/dist/zest.pt.min.js +1 -1
  39. package/dist/zest.ru.js +658 -50
  40. package/dist/zest.ru.js.map +1 -1
  41. package/dist/zest.ru.min.js +1 -1
  42. package/dist/zest.uk.js +658 -50
  43. package/dist/zest.uk.js.map +1 -1
  44. package/dist/zest.uk.min.js +1 -1
  45. package/dist/zest.zh.js +658 -50
  46. package/dist/zest.zh.js.map +1 -1
  47. package/dist/zest.zh.min.js +1 -1
  48. package/package.json +1 -1
  49. package/src/config/defaults.js +2 -1
  50. package/src/core/element-interceptor.js +374 -0
  51. package/src/core/network-interceptor.js +289 -0
  52. package/src/core-lifecycle.js +20 -1
  53. package/src/index.js +46 -18
  54. package/src/types/zest.d.ts +7 -0
  55. package/src/types/zest.headless.d.ts +7 -0
  56. package/zest.config.schema.json +26 -0
package/dist/zest.pl.js CHANGED
@@ -358,19 +358,19 @@ var Zest = (function () {
358
358
  // Upper bound on the number of queued cookies awaiting consent replay.
359
359
  // An unbounded queue is a memory-exhaustion DoS vector — a hostile
360
360
  // script could flood it with document.cookie writes.
361
- const MAX_QUEUE_SIZE$2 = 100;
361
+ const MAX_QUEUE_SIZE$3 = 100;
362
362
 
363
363
  // Queue for blocked cookies
364
364
  const cookieQueue = [];
365
365
 
366
366
  // Reference to consent checker function
367
- let checkConsent$3 = () => false;
367
+ let checkConsent$5 = () => false;
368
368
 
369
369
  /**
370
370
  * Set the consent checker function
371
371
  */
372
- function setConsentChecker$2(fn) {
373
- checkConsent$3 = fn;
372
+ function setConsentChecker$4(fn) {
373
+ checkConsent$5 = fn;
374
374
  }
375
375
 
376
376
  /**
@@ -426,10 +426,10 @@ var Zest = (function () {
426
426
 
427
427
  const category = getCategoryForName(name);
428
428
 
429
- if (checkConsent$3(category)) {
429
+ if (checkConsent$5(category)) {
430
430
  // Consent given - set cookie
431
431
  originalCookieDescriptor.set.call(document, value);
432
- } else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
432
+ } else if (cookieQueue.length < MAX_QUEUE_SIZE$3) {
433
433
  // No consent - queue for later (capped to prevent DoS)
434
434
  cookieQueue.push({
435
435
  value,
@@ -454,7 +454,7 @@ var Zest = (function () {
454
454
 
455
455
  // Upper bound on queued operations awaiting consent replay — unbounded
456
456
  // growth would be a memory-exhaustion DoS vector.
457
- const MAX_QUEUE_SIZE$1 = 200;
457
+ const MAX_QUEUE_SIZE$2 = 200;
458
458
 
459
459
  // Store originals
460
460
  let originalLocalStorage = null;
@@ -465,13 +465,13 @@ var Zest = (function () {
465
465
  const sessionStorageQueue = [];
466
466
 
467
467
  // Reference to consent checker function
468
- let checkConsent$2 = () => false;
468
+ let checkConsent$4 = () => false;
469
469
 
470
470
  /**
471
471
  * Set the consent checker function
472
472
  */
473
- function setConsentChecker$1(fn) {
474
- checkConsent$2 = fn;
473
+ function setConsentChecker$3(fn) {
474
+ checkConsent$4 = fn;
475
475
  }
476
476
 
477
477
  /**
@@ -484,9 +484,9 @@ var Zest = (function () {
484
484
  return (key, value) => {
485
485
  const category = getCategoryForName(key);
486
486
 
487
- if (checkConsent$2(category)) {
487
+ if (checkConsent$4(category)) {
488
488
  target.setItem(key, value);
489
- } else if (queue.length < MAX_QUEUE_SIZE$1) {
489
+ } else if (queue.length < MAX_QUEUE_SIZE$2) {
490
490
  queue.push({
491
491
  key,
492
492
  value,
@@ -769,11 +769,11 @@ var Zest = (function () {
769
769
 
770
770
  // Categories the author has declared blockable. A script can self-label
771
771
  // into one of these, but not into 'essential' (a common bypass).
772
- const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
772
+ const BLOCKABLE_CATEGORIES$2 = new Set(['functional', 'analytics', 'marketing']);
773
773
 
774
774
  // Upper bound on queued scripts awaiting consent replay — prevents a
775
775
  // hostile page from flooding the queue with <script> nodes.
776
- const MAX_QUEUE_SIZE = 500;
776
+ const MAX_QUEUE_SIZE$1 = 500;
777
777
 
778
778
  // Queue for blocked scripts — the authoritative source for replay,
779
779
  // snapshotting src/inline BEFORE any DOM mutation so later tampering
@@ -784,31 +784,31 @@ var Zest = (function () {
784
784
  let observer = null;
785
785
 
786
786
  // Current blocking mode
787
- let blockingMode = 'safe';
787
+ let blockingMode$2 = 'safe';
788
788
 
789
789
  // Custom blocked domains (user-defined)
790
- let customBlockedDomains = [];
790
+ let customBlockedDomains$2 = [];
791
791
 
792
792
  // Reference to consent checker function
793
- let checkConsent$1 = () => false;
793
+ let checkConsent$3 = () => false;
794
794
 
795
795
  /**
796
796
  * Set the consent checker function
797
797
  */
798
- function setConsentChecker(fn) {
799
- checkConsent$1 = fn;
798
+ function setConsentChecker$2(fn) {
799
+ checkConsent$3 = fn;
800
800
  }
801
801
 
802
802
  /**
803
803
  * Check if script URL matches custom blocked domains
804
804
  */
805
- function matchesCustomDomains(url) {
806
- if (!url || customBlockedDomains.length === 0) return null;
805
+ function matchesCustomDomains$2(url) {
806
+ if (!url || customBlockedDomains$2.length === 0) return null;
807
807
 
808
808
  try {
809
809
  const hostname = new URL(url).hostname.toLowerCase();
810
810
 
811
- for (const entry of customBlockedDomains) {
811
+ for (const entry of customBlockedDomains$2) {
812
812
  const domain = typeof entry === 'string' ? entry : entry.domain;
813
813
  const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
814
814
 
@@ -841,7 +841,7 @@ var Zest = (function () {
841
841
  // Only honor values from the blockable set; 'essential' and unknown
842
842
  // values fall through to the other checks.
843
843
  const explicitCategory = script.getAttribute('data-consent-category');
844
- const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
844
+ const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES$2.has(explicitCategory)
845
845
  ? explicitCategory
846
846
  : null;
847
847
 
@@ -853,17 +853,17 @@ var Zest = (function () {
853
853
  }
854
854
 
855
855
  // 2. Check custom blocked domains
856
- const customCategory = matchesCustomDomains(src);
856
+ const customCategory = matchesCustomDomains$2(src);
857
857
 
858
858
  // 3. Mode-based blocking
859
859
  let modeCategory = null;
860
- switch (blockingMode) {
860
+ switch (blockingMode$2) {
861
861
  case 'manual':
862
862
  break;
863
863
 
864
864
  case 'safe':
865
865
  case 'strict':
866
- modeCategory = getCategoryForScript(src, blockingMode);
866
+ modeCategory = getCategoryForScript(src, blockingMode$2);
867
867
  break;
868
868
 
869
869
  case 'doomsday':
@@ -897,7 +897,7 @@ var Zest = (function () {
897
897
  return false;
898
898
  }
899
899
 
900
- if (checkConsent$1(category)) {
900
+ if (checkConsent$3(category)) {
901
901
  // Consent already given - allow script
902
902
  script.setAttribute('data-zest-processed', 'allowed');
903
903
  return false;
@@ -931,7 +931,7 @@ var Zest = (function () {
931
931
  script.removeAttribute('src');
932
932
  }
933
933
 
934
- if (scriptQueue.length < MAX_QUEUE_SIZE) {
934
+ if (scriptQueue.length < MAX_QUEUE_SIZE$1) {
935
935
  scriptQueue.push(scriptInfo);
936
936
  }
937
937
  return true;
@@ -1012,8 +1012,8 @@ var Zest = (function () {
1012
1012
  * Start observing for new scripts
1013
1013
  */
1014
1014
  function startScriptBlocking(mode = 'safe', customDomains = []) {
1015
- blockingMode = mode;
1016
- customBlockedDomains = customDomains;
1015
+ blockingMode$2 = mode;
1016
+ customBlockedDomains$2 = customDomains;
1017
1017
 
1018
1018
  // Process existing scripts
1019
1019
  processExistingScripts();
@@ -1029,6 +1029,568 @@ var Zest = (function () {
1029
1029
  return true;
1030
1030
  }
1031
1031
 
1032
+ /**
1033
+ * Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
1034
+ *
1035
+ * Why this exists separately from the script blocker:
1036
+ *
1037
+ * Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
1038
+ * Webflow) increasingly proxy their tracker code through the site's own
1039
+ * origin to defeat ad-blockers. The <script> tag itself is first-party
1040
+ * (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
1041
+ * cannot match it. But at RUNTIME that script still phones home to the
1042
+ * vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
1043
+ * URL is third-party.
1044
+ *
1045
+ * This interceptor sits on the network layer and uses the same
1046
+ * customBlockedDomains + mode-based tracker list as the script blocker.
1047
+ * Whatever the user told Zest to block (typically generated by an AI
1048
+ * audit) gets blocked regardless of which API the tracker uses.
1049
+ *
1050
+ * No replay: network calls are one-shot and time-sensitive. Replaying a
1051
+ * stale beacon after consent would create confusing / duplicated data,
1052
+ * so blocked requests are dropped, not queued.
1053
+ */
1054
+
1055
+
1056
+ // Originals captured at install time. Stored for restoration tests and
1057
+ // for any internal Zest network calls we may add later.
1058
+ let originalFetch = null;
1059
+ let originalXhrOpen = null;
1060
+ let originalXhrSend = null;
1061
+ let originalSendBeacon = null;
1062
+
1063
+ let blockingMode$1 = 'safe';
1064
+ let customBlockedDomains$1 = [];
1065
+ let installed$1 = false;
1066
+
1067
+ let checkConsent$2 = () => false;
1068
+
1069
+ const BLOCKABLE_CATEGORIES$1 = new Set(['functional', 'analytics', 'marketing']);
1070
+
1071
+ function setConsentChecker$1(fn) {
1072
+ checkConsent$2 = fn;
1073
+ }
1074
+
1075
+ /**
1076
+ * Resolve a Request | URL | string to an absolute URL string. Returns
1077
+ * null if the input cannot be parsed — callers treat null as "do not
1078
+ * block" (we'd rather let an opaque request through than crash the page).
1079
+ */
1080
+ function resolveUrl(input) {
1081
+ try {
1082
+ if (typeof input === 'string') {
1083
+ return new URL(input, location.href).href;
1084
+ }
1085
+ if (input && typeof input === 'object') {
1086
+ if (typeof input.url === 'string') {
1087
+ // Request object
1088
+ return new URL(input.url, location.href).href;
1089
+ }
1090
+ if (typeof input.href === 'string') {
1091
+ // URL object
1092
+ return input.href;
1093
+ }
1094
+ }
1095
+ } catch (e) {
1096
+ // fallthrough
1097
+ }
1098
+ return null;
1099
+ }
1100
+
1101
+ /**
1102
+ * Match a URL against the user's customBlockedDomains list. Mirrors
1103
+ * matchesCustomDomains() in script-blocker.js — kept inline rather than
1104
+ * shared so each interceptor can be lifted independently.
1105
+ */
1106
+ function matchesCustomDomains$1(hostname) {
1107
+ if (!hostname || customBlockedDomains$1.length === 0) return null;
1108
+ const host = hostname.toLowerCase();
1109
+ for (const entry of customBlockedDomains$1) {
1110
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
1111
+ if (!domain) continue;
1112
+ const category = typeof entry === 'string'
1113
+ ? 'marketing'
1114
+ : (BLOCKABLE_CATEGORIES$1.has(entry?.category) ? entry.category : 'marketing');
1115
+ if (host === domain || host.endsWith('.' + domain)) {
1116
+ return category;
1117
+ }
1118
+ }
1119
+ return null;
1120
+ }
1121
+
1122
+ /**
1123
+ * Decide whether a URL should be blocked and return its category, or
1124
+ * null if it should pass through. Priority: customBlockedDomains >
1125
+ * mode-based tracker list (matching script-blocker priority).
1126
+ */
1127
+ function getBlockCategory$1(url) {
1128
+ if (!url) return null;
1129
+ let hostname;
1130
+ try {
1131
+ hostname = new URL(url, location.href).hostname;
1132
+ } catch (e) {
1133
+ return null;
1134
+ }
1135
+
1136
+ const customCategory = matchesCustomDomains$1(hostname);
1137
+ if (customCategory) return customCategory;
1138
+
1139
+ switch (blockingMode$1) {
1140
+ case 'manual':
1141
+ return null;
1142
+ case 'safe':
1143
+ case 'strict':
1144
+ return getCategoryForScript(url, blockingMode$1);
1145
+ case 'doomsday':
1146
+ if (isThirdParty(url)) {
1147
+ return getCategoryForScript(url, 'strict') || 'marketing';
1148
+ }
1149
+ return null;
1150
+ default:
1151
+ return null;
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * Should the request be blocked right now? Returns the category that
1157
+ * caused the block (for logging / callbacks later) or null.
1158
+ */
1159
+ function shouldBlock$1(url) {
1160
+ const category = getBlockCategory$1(url);
1161
+ if (!category) return null;
1162
+ if (checkConsent$2(category)) return null;
1163
+ return category;
1164
+ }
1165
+
1166
+ /**
1167
+ * Construct an empty, successful-looking Response for a blocked fetch.
1168
+ * Status 204 (No Content) is the most honest "we deliberately returned
1169
+ * nothing" signal. Trackers that .then(r => r.json()) will get an empty
1170
+ * body and typically silently move on.
1171
+ */
1172
+ function blockedResponse() {
1173
+ // Some environments (older browsers, strict CSP) may not have Response
1174
+ // — fall back to a thenable shape the most common tracker code expects.
1175
+ if (typeof Response === 'function') {
1176
+ return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
1177
+ }
1178
+ const fake = {
1179
+ ok: false,
1180
+ status: 204,
1181
+ statusText: 'Blocked by Zest',
1182
+ json: () => Promise.resolve({}),
1183
+ text: () => Promise.resolve(''),
1184
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
1185
+ };
1186
+ return fake;
1187
+ }
1188
+
1189
+ /**
1190
+ * Install fetch hook. Captures the original so we can both restore it
1191
+ * later and use it for any internal Zest network calls.
1192
+ */
1193
+ function patchFetch() {
1194
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
1195
+ originalFetch = window.fetch.bind(window);
1196
+
1197
+ window.fetch = function zestPatchedFetch(input, init) {
1198
+ const url = resolveUrl(input);
1199
+ if (shouldBlock$1(url)) {
1200
+ return Promise.resolve(blockedResponse());
1201
+ }
1202
+ return originalFetch(input, init);
1203
+ };
1204
+ }
1205
+
1206
+ /**
1207
+ * Install XMLHttpRequest hook. We patch .open() to capture the URL on
1208
+ * the instance, then .send() to decide whether to abort. Using a hidden
1209
+ * symbol on the instance avoids leaking state and survives any wrapping
1210
+ * code that reassigns request properties.
1211
+ */
1212
+ const URL_KEY = Symbol('zestUrl');
1213
+
1214
+ function patchXhr() {
1215
+ if (typeof XMLHttpRequest === 'undefined') return;
1216
+ const proto = XMLHttpRequest.prototype;
1217
+ originalXhrOpen = proto.open;
1218
+ originalXhrSend = proto.send;
1219
+
1220
+ proto.open = function (method, url, ...rest) {
1221
+ this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
1222
+ return originalXhrOpen.call(this, method, url, ...rest);
1223
+ };
1224
+
1225
+ proto.send = function (body) {
1226
+ const url = this[URL_KEY];
1227
+ if (shouldBlock$1(url)) {
1228
+ // Mimic the failure mode of a network error: queueMicrotask is
1229
+ // used so consumers that synchronously attach handlers after
1230
+ // .send() still receive the events.
1231
+ const xhr = this;
1232
+ queueMicrotask(() => {
1233
+ try {
1234
+ // Best-effort — readonly props in some environments
1235
+ Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
1236
+ Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
1237
+ } catch (e) {
1238
+ // ignore
1239
+ }
1240
+ try {
1241
+ xhr.dispatchEvent(new Event('error'));
1242
+ xhr.dispatchEvent(new Event('loadend'));
1243
+ } catch (e) {
1244
+ // ignore
1245
+ }
1246
+ });
1247
+ return;
1248
+ }
1249
+ return originalXhrSend.call(this, body);
1250
+ };
1251
+ }
1252
+
1253
+ /**
1254
+ * Install navigator.sendBeacon hook. Returning false matches the spec's
1255
+ * "data was not queued" semantics; trackers that check the return value
1256
+ * fall back to fetch (which we also block) or give up.
1257
+ */
1258
+ function patchSendBeacon() {
1259
+ if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
1260
+ originalSendBeacon = navigator.sendBeacon.bind(navigator);
1261
+
1262
+ navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
1263
+ if (shouldBlock$1(typeof url === 'string' ? url : (url && url.href) || '')) {
1264
+ return false;
1265
+ }
1266
+ return originalSendBeacon(url, data);
1267
+ };
1268
+ }
1269
+
1270
+ /**
1271
+ * Install all network hooks. Safe to call multiple times — subsequent
1272
+ * calls just refresh mode + custom domain config without re-wrapping.
1273
+ */
1274
+ function interceptNetwork(mode = 'safe', customDomains = []) {
1275
+ blockingMode$1 = mode;
1276
+ customBlockedDomains$1 = Array.isArray(customDomains) ? customDomains : [];
1277
+
1278
+ if (installed$1) return true;
1279
+ patchFetch();
1280
+ patchXhr();
1281
+ patchSendBeacon();
1282
+ installed$1 = true;
1283
+ return true;
1284
+ }
1285
+
1286
+ /**
1287
+ * Element Interceptor - Catches tracker elements BEFORE the browser fetches them.
1288
+ *
1289
+ * The script-blocker uses MutationObserver. That fires asynchronously
1290
+ * (microtask after the DOM mutation), so by the time we can react the
1291
+ * browser has already kicked off the network request for the src/href.
1292
+ * The script may not execute (we flip type to text/plain) but the
1293
+ * fetch already left the building — and to ConsentTheater / a privacy
1294
+ * audit that fetch IS a pre-consent leak.
1295
+ *
1296
+ * This interceptor patches the prototype setters and Element.setAttribute
1297
+ * synchronously, so when code does:
1298
+ *
1299
+ * const s = document.createElement('script');
1300
+ * s.src = 'https://tracker.example/track.js'; // ← intercepted HERE
1301
+ * document.head.appendChild(s); // ← src is already empty,
1302
+ * // no fetch ever fired
1303
+ *
1304
+ * Covers four element types and both ways to set the URL:
1305
+ *
1306
+ * - HTMLScriptElement src
1307
+ * - HTMLLinkElement href (stylesheets, prefetch, preload, dns-prefetch)
1308
+ * - HTMLImageElement src (tracking pixels)
1309
+ * - HTMLIFrameElement src (tracking iframes)
1310
+ *
1311
+ * Plus the global Image() constructor used by classic pixel trackers.
1312
+ *
1313
+ * What this does NOT catch: inline HTML <script src=...> / <link href=...>
1314
+ * tags parsed from the original HTML response. The browser starts those
1315
+ * fetches as soon as it encounters the tag during parsing, BEFORE any
1316
+ * JavaScript runs. The only complete fix for that class is server-side
1317
+ * CSP or template-time removal.
1318
+ */
1319
+
1320
+
1321
+ // Upper bound on queued blocked elements. Unbounded growth would be a
1322
+ // memory-exhaustion vector if a page (or a hostile script) tried to
1323
+ // flood us with src writes.
1324
+ const MAX_QUEUE_SIZE = 500;
1325
+
1326
+ // Queue of blocked element writes. Each entry remembers enough to
1327
+ // re-apply the original URL via the ORIGINAL setter once consent
1328
+ // arrives for its category. Without this queue, blocked scripts /
1329
+ // stylesheets / images would be lost forever and require a page
1330
+ // reload to come back.
1331
+ const elementQueue = [];
1332
+
1333
+ let blockingMode = 'safe';
1334
+ let customBlockedDomains = [];
1335
+ let installed = false;
1336
+ let checkConsent$1 = () => false;
1337
+
1338
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
1339
+
1340
+ // Map of tag name -> attribute name that carries a URL we may want to
1341
+ // block. Lowercased on both sides; setAttribute() gating uses this.
1342
+ const URL_ATTRS = {
1343
+ script: 'src',
1344
+ link: 'href',
1345
+ img: 'src',
1346
+ iframe: 'src'
1347
+ };
1348
+
1349
+ function setConsentChecker(fn) {
1350
+ checkConsent$1 = fn;
1351
+ }
1352
+
1353
+ function matchesCustomDomains(hostname) {
1354
+ if (!hostname || customBlockedDomains.length === 0) return null;
1355
+ const host = hostname.toLowerCase();
1356
+ for (const entry of customBlockedDomains) {
1357
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
1358
+ if (!domain) continue;
1359
+ const category = typeof entry === 'string'
1360
+ ? 'marketing'
1361
+ : (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
1362
+ if (host === domain || host.endsWith('.' + domain)) {
1363
+ return category;
1364
+ }
1365
+ }
1366
+ return null;
1367
+ }
1368
+
1369
+ function getBlockCategory(url) {
1370
+ if (!url) return null;
1371
+ let hostname;
1372
+ try {
1373
+ hostname = new URL(url, location.href).hostname;
1374
+ } catch (e) {
1375
+ return null;
1376
+ }
1377
+
1378
+ const customCategory = matchesCustomDomains(hostname);
1379
+ if (customCategory) return customCategory;
1380
+
1381
+ switch (blockingMode) {
1382
+ case 'manual':
1383
+ return null;
1384
+ case 'safe':
1385
+ case 'strict':
1386
+ return getCategoryForScript(url, blockingMode);
1387
+ case 'doomsday':
1388
+ if (isThirdParty(url)) {
1389
+ return getCategoryForScript(url, 'strict') || 'marketing';
1390
+ }
1391
+ return null;
1392
+ default:
1393
+ return null;
1394
+ }
1395
+ }
1396
+
1397
+ function shouldBlock(url) {
1398
+ const category = getBlockCategory(url);
1399
+ if (!category) return null;
1400
+ if (checkConsent$1(category)) return null;
1401
+ return category;
1402
+ }
1403
+
1404
+ /**
1405
+ * Replace the property setter for `prop` on `ProtoCtor.prototype` with
1406
+ * a gated version. Returns the original descriptor so we can restore.
1407
+ */
1408
+ function patchUrlSetter(ProtoCtor, prop) {
1409
+ if (typeof ProtoCtor !== 'function' || !ProtoCtor.prototype) return null;
1410
+ const proto = ProtoCtor.prototype;
1411
+ const desc = Object.getOwnPropertyDescriptor(proto, prop);
1412
+ if (!desc || typeof desc.set !== 'function') return null;
1413
+
1414
+ Object.defineProperty(proto, prop, {
1415
+ configurable: true,
1416
+ enumerable: desc.enumerable,
1417
+ get: desc.get,
1418
+ set(value) {
1419
+ if (typeof value === 'string') {
1420
+ const category = shouldBlock(value);
1421
+ if (category) {
1422
+ // Don't pass through to the original setter — the URL never
1423
+ // touches the element. Stash the element + URL + category
1424
+ // + original descriptor in the queue so replayElements()
1425
+ // can reinstate it once consent arrives.
1426
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1427
+ elementQueue.push({
1428
+ element: this,
1429
+ setter: desc.set,
1430
+ prop,
1431
+ value,
1432
+ category,
1433
+ method: 'property'
1434
+ });
1435
+ }
1436
+ return;
1437
+ }
1438
+ }
1439
+ return desc.set.call(this, value);
1440
+ }
1441
+ });
1442
+
1443
+ return desc;
1444
+ }
1445
+
1446
+ function patchSetAttribute() {
1447
+ if (typeof Element === 'undefined' || !Element.prototype) return null;
1448
+ const orig = Element.prototype.setAttribute;
1449
+
1450
+ Element.prototype.setAttribute = function patchedSetAttribute(name, value) {
1451
+ // Fast path: bail out for anything not on our watchlist before doing
1452
+ // any string work. setAttribute is hot — keep this cheap.
1453
+ if (typeof name !== 'string' || typeof value !== 'string' || !this || !this.tagName) {
1454
+ return orig.call(this, name, value);
1455
+ }
1456
+ const tag = this.tagName.toLowerCase();
1457
+ const watched = URL_ATTRS[tag];
1458
+ if (!watched) {
1459
+ return orig.call(this, name, value);
1460
+ }
1461
+ const attr = name.toLowerCase();
1462
+ if (attr !== watched) {
1463
+ return orig.call(this, name, value);
1464
+ }
1465
+ const category = shouldBlock(value);
1466
+ if (category) {
1467
+ // Drop silently and queue for replay. The element keeps any
1468
+ // other attributes you set before / after.
1469
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1470
+ elementQueue.push({
1471
+ element: this,
1472
+ setter: orig, // setAttribute itself, called like orig.call(el, name, value)
1473
+ prop: name,
1474
+ value,
1475
+ category,
1476
+ method: 'attribute'
1477
+ });
1478
+ }
1479
+ return;
1480
+ }
1481
+ return orig.call(this, name, value);
1482
+ };
1483
+
1484
+ return orig;
1485
+ }
1486
+
1487
+ function patchImageConstructor() {
1488
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') return null;
1489
+ const OrigImage = window.Image;
1490
+
1491
+ function PatchedImage(width, height) {
1492
+ const img = arguments.length >= 2
1493
+ ? new OrigImage(width, height)
1494
+ : new OrigImage();
1495
+ // No work needed here — the .src setter patch on HTMLImageElement
1496
+ // will catch any later assignment. PatchedImage exists mainly to
1497
+ // expose the .src patch via this path for `new Image()` users.
1498
+ return img;
1499
+ }
1500
+ PatchedImage.prototype = OrigImage.prototype;
1501
+ // Copy any static fields just in case.
1502
+ for (const key of Object.keys(OrigImage)) {
1503
+ try { PatchedImage[key] = OrigImage[key]; } catch (e) { /* ignore */ }
1504
+ }
1505
+
1506
+ try {
1507
+ Object.defineProperty(window, 'Image', {
1508
+ configurable: true,
1509
+ writable: true,
1510
+ value: PatchedImage
1511
+ });
1512
+ } catch (e) {
1513
+ window.Image = PatchedImage;
1514
+ }
1515
+
1516
+ return OrigImage;
1517
+ }
1518
+
1519
+ /**
1520
+ * Replay blocked element writes for newly-allowed categories.
1521
+ *
1522
+ * For each queued entry whose category is in `allowedCategories`:
1523
+ * - If the element is still connected to the DOM, re-apply the
1524
+ * URL via the ORIGINAL setter / setAttribute. The browser starts
1525
+ * the fetch as if nothing had been intercepted.
1526
+ * - If the element has since been removed (no `isConnected`), drop
1527
+ * the entry — calling code lost its reference and we have no
1528
+ * parent to attach to.
1529
+ *
1530
+ * Queue ordering is preserved so that scripts/stylesheets re-execute
1531
+ * in the same order the page originally requested them.
1532
+ */
1533
+ function replayElements(allowedCategories) {
1534
+ if (!Array.isArray(allowedCategories) || elementQueue.length === 0) return;
1535
+ const remaining = [];
1536
+
1537
+ for (const item of elementQueue) {
1538
+ if (!allowedCategories.includes(item.category)) {
1539
+ remaining.push(item);
1540
+ continue;
1541
+ }
1542
+
1543
+ const el = item.element;
1544
+ if (!el || !el.isConnected) {
1545
+ // Element is detached or gone — nothing to re-apply against.
1546
+ continue;
1547
+ }
1548
+
1549
+ try {
1550
+ if (item.method === 'attribute') {
1551
+ item.setter.call(el, item.prop, item.value);
1552
+ } else {
1553
+ item.setter.call(el, item.value);
1554
+ }
1555
+ } catch (e) {
1556
+ // Restoration failed (rare — element might be in a weird state).
1557
+ // Don't requeue; one failure is enough.
1558
+ }
1559
+ }
1560
+
1561
+ elementQueue.length = 0;
1562
+ elementQueue.push(...remaining);
1563
+ }
1564
+
1565
+ /**
1566
+ * Install all element-level interceptors. Idempotent — second call
1567
+ * just refreshes mode + customDomains without rewrapping.
1568
+ */
1569
+ function interceptElements(mode = 'safe', customDomains = []) {
1570
+ blockingMode = mode;
1571
+ customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
1572
+
1573
+ if (installed) return true;
1574
+
1575
+ if (typeof HTMLScriptElement !== 'undefined') {
1576
+ patchUrlSetter(HTMLScriptElement, 'src');
1577
+ }
1578
+ if (typeof HTMLLinkElement !== 'undefined') {
1579
+ patchUrlSetter(HTMLLinkElement, 'href');
1580
+ }
1581
+ if (typeof HTMLImageElement !== 'undefined') {
1582
+ patchUrlSetter(HTMLImageElement, 'src');
1583
+ }
1584
+ if (typeof HTMLIFrameElement !== 'undefined') {
1585
+ patchUrlSetter(HTMLIFrameElement, 'src');
1586
+ }
1587
+ patchSetAttribute();
1588
+ patchImageConstructor();
1589
+
1590
+ installed = true;
1591
+ return true;
1592
+ }
1593
+
1032
1594
  /**
1033
1595
  * Default consent categories
1034
1596
  */
@@ -1336,7 +1898,8 @@ var Zest = (function () {
1336
1898
  intercept: {
1337
1899
  cookies: true,
1338
1900
  storage: true,
1339
- scripts: true
1901
+ scripts: true,
1902
+ network: true
1340
1903
  },
1341
1904
 
1342
1905
  // Strictly-necessary declarations. Both fields *append* to whatever
@@ -1870,6 +2433,11 @@ var Zest = (function () {
1870
2433
  replayCookies(categories);
1871
2434
  replayStorage(categories);
1872
2435
  replayScripts(categories);
2436
+ // Element-level replays (script/link/img/iframe URLs that were
2437
+ // dropped at the prototype-setter / setAttribute layer). Network
2438
+ // interceptor (fetch/XHR/sendBeacon) intentionally has no replay
2439
+ // — beacons are one-shot and resending would duplicate analytics.
2440
+ replayElements(categories);
1873
2441
  }
1874
2442
 
1875
2443
  /**
@@ -1914,6 +2482,8 @@ var Zest = (function () {
1914
2482
  });
1915
2483
  }
1916
2484
 
2485
+ setConsentChecker$4(checkConsent);
2486
+ setConsentChecker$3(checkConsent);
1917
2487
  setConsentChecker$2(checkConsent);
1918
2488
  setConsentChecker$1(checkConsent);
1919
2489
  setConsentChecker(checkConsent);
@@ -1921,12 +2491,22 @@ var Zest = (function () {
1921
2491
  // Interceptor toggles. By default everything is intercepted (back-compat
1922
2492
  // with v2.0 / v2.1). Consumers that gate scripts and storage themselves
1923
2493
  // can opt out per channel via `intercept: { storage: false, … }`.
1924
- const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true };
2494
+ const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
1925
2495
  if (intercept.cookies !== false) interceptCookies();
1926
2496
  if (intercept.storage !== false) interceptStorage();
1927
2497
  if (intercept.scripts !== false) {
2498
+ // Element-level synchronous interception (prototype setters +
2499
+ // setAttribute) installs BEFORE startScriptBlocking so that the
2500
+ // moment any later script does `el.src = "https://tracker..."`,
2501
+ // we drop the URL before the browser fetches. The MutationObserver
2502
+ // inside startScriptBlocking remains as a defence-in-depth net for
2503
+ // anything that slips past (e.g. nodes constructed via cloneNode).
2504
+ interceptElements(currentConfig.mode, currentConfig.blockedDomains);
1928
2505
  startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
1929
2506
  }
2507
+ if (intercept.network !== false) {
2508
+ interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
2509
+ }
1930
2510
 
1931
2511
  const consent = loadConsent();
1932
2512
  initialized = true;
@@ -3065,18 +3645,29 @@ ${customCss}
3065
3645
  }
3066
3646
 
3067
3647
  /**
3068
- * Initialize Zest with UI.
3648
+ * UI mount guard. We split UI mounting (which needs `<body>` and a parsed
3649
+ * DOM) from interceptor installation (which must happen on script eval to
3650
+ * gate any later `defer` / `async` tracker scripts). `coreInit()` is
3651
+ * idempotent so calling init() before the DOM is ready is safe — the UI
3652
+ * portion just gets queued.
3069
3653
  */
3070
- function init(userConfig = {}) {
3071
- const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
3072
- if (alreadyInitialized) {
3073
- console.warn('[Zest] Already initialized');
3074
- return Zest;
3654
+ let uiMounted = false;
3655
+
3656
+ function mountUI() {
3657
+ if (uiMounted) return;
3658
+
3659
+ // Banner needs document.body to mount its host element. If body isn't
3660
+ // there yet, requeue on DOMContentLoaded.
3661
+ if (!document || !document.body) {
3662
+ document.addEventListener('DOMContentLoaded', mountUI, { once: true });
3663
+ return;
3075
3664
  }
3076
3665
 
3666
+ uiMounted = true;
3077
3667
  const config = getActiveConfig();
3668
+ const decision = hasConsentDecision();
3078
3669
 
3079
- if (!hasDecision && !dntApplied) {
3670
+ if (!decision) {
3080
3671
  showBanner({
3081
3672
  onAcceptAll: handleAcceptAll,
3082
3673
  onRejectAll: handleRejectAll,
@@ -3086,7 +3677,27 @@ ${customCss}
3086
3677
  } else if (config?.showWidget) {
3087
3678
  showWidget({ onClick: handleShowSettings });
3088
3679
  }
3680
+ }
3089
3681
 
3682
+ /**
3683
+ * Initialize Zest with UI.
3684
+ *
3685
+ * Splits into two phases:
3686
+ *
3687
+ * 1. `coreInit()` runs synchronously: interceptors install on the
3688
+ * cookie / storage / script / network channels immediately so any
3689
+ * `defer` or `async` script that fires later is already gated.
3690
+ * Critical — DOMContentLoaded fires AFTER `defer` scripts execute,
3691
+ * so deferring interceptor install means trackers fire first.
3692
+ *
3693
+ * 2. UI mount (banner / widget) is queued until `<body>` exists. If
3694
+ * this script runs in `<head>` while the document is still
3695
+ * parsing, that means waiting for DOMContentLoaded; if it runs
3696
+ * after, mount happens immediately.
3697
+ */
3698
+ function init(userConfig = {}) {
3699
+ coreInit(userConfig);
3700
+ mountUI();
3090
3701
  return Zest;
3091
3702
  }
3092
3703
 
@@ -3195,17 +3806,14 @@ ${customCss}
3195
3806
  window.Zest = Zest;
3196
3807
  }
3197
3808
 
3198
- const autoInit = () => {
3199
- const cfg = getConfig();
3200
- if (cfg.autoInit !== false) {
3201
- init(window.ZestConfig);
3202
- }
3203
- };
3204
-
3205
- if (document.readyState === 'loading') {
3206
- document.addEventListener('DOMContentLoaded', autoInit);
3207
- } else {
3208
- autoInit();
3809
+ // Run init() synchronously on script eval. init() itself splits the
3810
+ // work interceptors install now, UI mount waits for <body> if
3811
+ // needed. No DOMContentLoaded wait at this layer: deferring init()
3812
+ // would let any `defer` / `async` tracker script fire its network
3813
+ // calls before our interceptors are in place.
3814
+ const cfg = getConfig();
3815
+ if (cfg.autoInit !== false) {
3816
+ init(window.ZestConfig);
3209
3817
  }
3210
3818
  }
3211
3819