@ecomconsult/consentkit 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,14 +10,19 @@ trackers *before* they run, a Shadow DOM banner, and Google Consent Mode v2.
10
10
 
11
11
  Most cookie banners are decoration — the trackers fire on the first frame no
12
12
  matter which button you press. ConsentKit blocks at parse time: nothing but
13
- `necessary` runs until the visitor says so, and no request leaves the page
14
- before that, including requests to a CDN or a font host.
13
+ `necessary` runs until the visitor says so. Dynamically injected trackers (the
14
+ official Metrika / GTM / Meta / TikTok / Hotjar snippets) and anything marked
15
+ `type="text/plain"` never fire a request; a plain `<script src>` tag written
16
+ into the HTML is prevented from executing and setting cookies, but its network
17
+ request may already be in flight — mark such tags up manually.
15
18
 
16
19
  Vanilla ES2020, zero dependencies, no build step.
17
20
 
18
21
  - **Categories:** `necessary` (always on), `functional`, `analytics`, `marketing`
19
22
  - **Blocking:** manual markup (`type="text/plain"`) plus automatic interception
20
- of dynamically injected scripts
23
+ of dynamically injected scripts. Plain `<script src>` tags written into the HTML
24
+ (e.g. a direct GA4 `gtag/js` tag) cannot be intercepted before the request
25
+ leaves — mark those up manually; the SaaS install check points at the exact tag
21
26
  - **UI:** banner (`bar` / `box` / `modal`), preferences panel, floating re-open
22
27
  button, light/dark, 30+ locales
23
28
  - **SSR-safe:** importing on the server never touches the DOM
@@ -310,6 +315,156 @@ The decision is stored in a `ck_consent` cookie (base64 JSON, `path=/`,
310
315
  discarded — and the banner shown again — when `policyVersion` changes or the TTL
311
316
  expires.
312
317
 
318
+ ## Branding
319
+
320
+ By default the banner shows a small "Made by E-COM Consult" attribution line,
321
+ linking to ecomconsult.net. It is emitted as a `branding` object in the config,
322
+ and the prebuilt blocks in `ready/` carry it.
323
+
324
+ Removing it is a supported, first-class option — no obligation, no nag:
325
+
326
+ ```sh
327
+ node tools/build-inline.mjs --langs=en,ru --no-branding # block without it
328
+ ```
329
+
330
+ If you write the config by hand, simply omit the `branding` object; nothing
331
+ renders without it. Either way costs you ~200 bytes, not a licence: the client
332
+ is MIT and the line is yours to drop.
333
+
334
+ Note that only the attribution line ships, not the logo — `brand/ecom-consult-logo.svg`
335
+ is a white wordmark authored for dark backgrounds, so it would be invisible on
336
+ the banner's light surface. Supply your own `branding.logo` (and `logoDark`) if
337
+ you want a mark; see the branding notes in `src/ck-ui.js`.
338
+
339
+ In the **hosted service** the line is on by default and switching it off is part
340
+ of the paid plans. To be clear about what is being charged for: the fee covers
341
+ hosting, the cabinet and the scanner — not the line itself.
342
+
343
+ ## Prebuilt inline blocks
344
+
345
+ For site builders that will not let you upload files, `ready/` holds
346
+ ready-to-paste `<script>` blocks — copy one wholesale into `<head>`. Zero
347
+ external requests. Rebuild them with `tools/build-inline.mjs` (see
348
+ [`tools/README.md`](tools/README.md)); each block's header records the exact
349
+ command that produced it.
350
+
351
+ ConsentKit 0.3.5, rebuilt 2026-09-04, uncompressed — gzip on the server cuts
352
+ this roughly three- to fourfold. Every block includes the attribution line;
353
+ `--no-branding` takes ~200 bytes back off:
354
+
355
+ | Block | Languages | Bytes |
356
+ |---|---|---|
357
+ | `ready/en-bar.txt` | en | 111,664 |
358
+ | `ready/ru-bar.txt` | ru, ro, en | 113,285 |
359
+ | `ready/ru-box.txt` | ru, ro, en | 113,300 |
360
+ | `ready/ru-box-right.txt` | ru, ro, en | 113,309 |
361
+ | `ready/ru-modal.txt` | ru, ro, en | 113,293 |
362
+ | `ready/eu-bar.txt` | 34 languages | 161,939 |
363
+
364
+ Size is driven almost entirely by the bundled languages: `en` and `ru` are
365
+ built into the UI and cost nothing extra, while layout, position, theme and
366
+ accent change only a few bytes of config.
367
+
368
+ The debug panel is **not** in these numbers. Blocks carry a ~5.1 KB loader
369
+ (`src/ck-debug-loader.js`) which fetches the 33 KB panel only when someone opens
370
+ the page with `?ck_debug=1` — see [Debug mode](#debug-mode). An ordinary visitor
371
+ downloads the loader and nothing more.
372
+
373
+ ## Debug mode
374
+
375
+ A panel that shows what the client actually did on a live page — useful when a
376
+ site owner asks "is this thing working?" and screenshots of the banner do not
377
+ answer it.
378
+
379
+ **Open it** by adding `?ck_debug=1` to the page URL (`#ck_debug` works too):
380
+
381
+ ```
382
+ https://example.com/?ck_debug=1
383
+ ```
384
+
385
+ A dark, monospace panel appears bottom-right in its own Shadow DOM. The flag is
386
+ remembered in `localStorage` for that browser, so it survives navigation.
387
+
388
+ **It shows**, in order: the client version, config source (SaaS or inline),
389
+ siteId and policy version / ETag; the consent status, granted categories,
390
+ decision time and cookie lifetime; what the blocking engine is holding back
391
+ (`ConsentKit._blocked()`); which requests to known trackers actually left the
392
+ page, each marked *before* or *after* consent; the recent `ck_*` dataLayer
393
+ events and `gtag('consent', …)` calls; and three buttons — reset consent
394
+ (withdraw, clear the cookie, reload), open preferences, and copy a JSON report.
395
+
396
+ The request list carries the same caveat as the blocking engine itself:
397
+ requests that left **before** ck-core.js parsed — a plain `<script src>` written
398
+ into the HTML — show up there but could not have been blocked. Mark those tags
399
+ up manually.
400
+
401
+ **Turn it off** with `?ck_debug=0`, or the × in the panel's header. The loader
402
+ owns the stored flag, so `?ck_debug=0` clears it whether or not the panel is on
403
+ the page.
404
+
405
+ **Privacy.** Nothing is sent anywhere: the panel is local to that browser and
406
+ that page. It never renders cookie *values* (names only) and never shows URL
407
+ query strings (host and path only), so the copied report is safe to paste into
408
+ a support ticket.
409
+
410
+ ### How the panel gets onto the page
411
+
412
+ The panel is ~33 KB, and on any given page exactly one person will ever open
413
+ it. So `ready/*.txt` and the WordPress plugin ship **`src/ck-debug-loader.js`**
414
+ (~5.1 KB) instead, and the loader fetches the panel on demand. With no flag set
415
+ the loader creates no DOM, installs no observers and makes **no network
416
+ request** — it costs its own bytes and nothing else.
417
+
418
+ When the panel *is* activated, the loader resolves its URL in this order, first
419
+ match wins:
420
+
421
+ 1. **`window.ConsentKitDebugUrl`**, if you set it — a self-hosted copy, an
422
+ internal mirror, or a pinned build. Set it before the loader runs.
423
+ 2. **`<API_BASE>/client/ck-debug.js`**, when `ck-saas.js` is on the page with a
424
+ site id — `API_BASE` is that loader's `data-ck-api` (its `ConsentKit._saas.api`).
425
+ The panel then comes from the same origin as the config, so a locked-down CSP
426
+ needs no extra host. **A SaaS deployment is expected to serve the panel at
427
+ that path**; if yours does not, set `window.ConsentKitDebugUrl` instead.
428
+ 3. **jsDelivr**, pinned to the running core version:
429
+ `https://cdn.jsdelivr.net/npm/@ecomconsult/consentkit@<version>/src/ck-debug.js`.
430
+ The version comes from `ConsentKit.version`, so the panel can never be newer
431
+ or older than the client it is reporting on. This path is published because
432
+ `package.json` lists `src` in `files`.
433
+
434
+ The script is injected `async`, and neither jsDelivr nor a ConsentKit API host
435
+ is in the blocking engine's tracker list, so the panel loads even while the
436
+ visitor has yet to decide.
437
+
438
+ ### Loading the panel directly
439
+
440
+ If you host the files yourself and would rather have the panel inline, load it
441
+ after `ck-ui.js` (and after `ck-saas.js` if you use it) and skip the loader:
442
+
443
+ ```html
444
+ <script src="/js/ck-core.js"></script>
445
+ <script src="/js/ck-locales.js"></script>
446
+ <script src="/js/ck-ui.js"></script>
447
+ <script src="/js/ck-debug.js"></script>
448
+ ```
449
+
450
+ `ck-debug.js` is self-contained: it repeats the loader's activation check, so it
451
+ works with or without the loader. Do **not** add it to a page that already ships
452
+ the loader (an inline block from `ready/`, or the WordPress plugin) — the loader
453
+ fetches the panel itself, and a second copy would mount a second panel.
454
+
455
+ From npm it is a deliberate opt-in — `@ecomconsult/consentkit` does not pull
456
+ it in for you. Import it yourself, after the UI:
457
+
458
+ ```js
459
+ import '@ecomconsult/consentkit'; // core + locales + UI
460
+ import '@ecomconsult/consentkit/src/ck-debug.js';
461
+ ```
462
+
463
+ **Language.** The panel is Russian or English: it follows the banner's
464
+ configured `language`, falls back to `navigator.language` (`ru-*` → Russian),
465
+ and otherwise renders English. The JSON report it copies is language-neutral
466
+ whichever way the panel reads.
467
+
313
468
  ## TypeScript
314
469
 
315
470
  Types ship with the package; no `@types` needed.
package/npm/core.cjs CHANGED
@@ -21,7 +21,7 @@ function undecidedState() {
21
21
 
22
22
  function createStub() {
23
23
  return {
24
- version: '0.2.0',
24
+ version: '0.3.5',
25
25
  config: {},
26
26
  init: function () { return undecidedState(); },
27
27
  allowed: function (cat) { return cat === 'necessary'; },
package/npm/index.cjs CHANGED
@@ -24,7 +24,7 @@ function undecidedState() {
24
24
 
25
25
  function createStub() {
26
26
  return {
27
- version: '0.2.0',
27
+ version: '0.3.5',
28
28
  config: {},
29
29
  init: function () { return undecidedState(); },
30
30
  allowed: function (cat) { return cat === 'necessary'; },
@@ -28,7 +28,7 @@ export function undecidedState() {
28
28
  */
29
29
  export function createStub() {
30
30
  const stub = {
31
- version: '0.2.0',
31
+ version: '0.3.5',
32
32
  config: {},
33
33
  init: function () { return undecidedState(); },
34
34
  allowed: function (cat) { return cat === 'necessary'; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomconsult/consentkit",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "GDPR cookie consent core with blocking engine, Shadow DOM UI and Google Consent Mode v2. Zero dependencies, no build step.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,6 +20,9 @@
20
20
  ],
21
21
  "author": "E-COM CONSULT PLUS",
22
22
  "license": "MIT",
23
+ "scripts": {
24
+ "test": "node --test test/*.test.mjs"
25
+ },
23
26
  "type": "module",
24
27
  "sideEffects": true,
25
28
  "types": "./npm/index.d.ts",
package/src/ck-core.js CHANGED
@@ -629,7 +629,62 @@
629
629
  return !allowed(cat);
630
630
  }
631
631
 
632
+ // Registry of everything the engine intercepted, for the debug panel (§8.1
633
+ // item 3). Kept deliberately small: host + path only, никаких query strings —
634
+ // a tracker URL's query carries ids and, on badly built sites, PII.
635
+ // Capped so a page that injects trackers in a loop cannot grow it without
636
+ // bound; the panel shows the first BLOCKED_MAX, which is always enough to see
637
+ // what is happening.
638
+ var BLOCKED_MAX = 200;
639
+ var blockedLog = [];
640
+
641
+ // host + path, query and fragment dropped.
642
+ function safeUrlParts(src) {
643
+ var host = '';
644
+ var path = '';
645
+ try {
646
+ var base = (global.location && global.location.href) || 'http://localhost/';
647
+ var u = new URL(String(src), base);
648
+ host = (u.hostname || '').toLowerCase();
649
+ path = u.pathname || '';
650
+ } catch (e) {
651
+ var s = String(src || '');
652
+ var m = /^(?:[a-z]+:)?\/\/([^/?#]+)([^?#]*)/i.exec(s);
653
+ if (m) {
654
+ host = m[1].toLowerCase().replace(/:\d+$/, '');
655
+ path = m[2] || '';
656
+ } else {
657
+ path = s.split('?')[0].split('#')[0];
658
+ }
659
+ }
660
+ return { host: host, path: path };
661
+ }
662
+
663
+ function noteBlocked(el, src, cat, origin) {
664
+ try {
665
+ if (blockedLog.length >= BLOCKED_MAX) { return; }
666
+ var kind = 'script';
667
+ try {
668
+ var tag = el && el.tagName ? String(el.tagName).toLowerCase() : '';
669
+ if (tag) { kind = tag === 'img' ? 'img' : tag; }
670
+ } catch (e2) { /* noop */ }
671
+ var parts = safeUrlParts(src);
672
+ for (var i = 0; i < blockedLog.length; i++) {
673
+ var p = blockedLog[i];
674
+ if (p.host === parts.host && p.path === parts.path && p.kind === kind) { return; }
675
+ }
676
+ blockedLog.push({
677
+ host: parts.host,
678
+ path: parts.path,
679
+ kind: kind,
680
+ category: cat || null,
681
+ origin: origin || 'engine'
682
+ });
683
+ } catch (e) { /* noop */ }
684
+ }
685
+
632
686
  function markBlocked(el, src, cat) {
687
+ noteBlocked(el, src, cat, 'engine');
633
688
  try {
634
689
  if (nativeSetAttribute) {
635
690
  nativeSetAttribute.call(el, 'data-ck-blocked', '1');
@@ -961,7 +1016,7 @@
961
1016
  // Public API
962
1017
  // ---------------------------------------------------------------------------
963
1018
  var ConsentKit = {
964
- version: '0.3.2',
1019
+ version: '0.3.5',
965
1020
  config: config,
966
1021
 
967
1022
  init: function (userConfig) {
@@ -1017,7 +1072,58 @@
1017
1072
 
1018
1073
  // Introspection helpers for the demo status panel (read-only).
1019
1074
  _categoryForUrl: categoryForUrl,
1020
- _categories: CATEGORIES.slice()
1075
+ _categories: CATEGORIES.slice(),
1076
+
1077
+ // What is being held back until consent: everything the engine intercepted
1078
+ // (origin 'engine') plus what the site author marked up by hand (origin
1079
+ // 'markup'), which never goes through markBlocked. Read-only, host+path
1080
+ // only — no query strings, so no ids and no PII. Used by src/ck-debug.js.
1081
+ _blocked: function () {
1082
+ var out = [];
1083
+ var seen = {};
1084
+ function add(rec) {
1085
+ var key = rec.kind + '|' + rec.host + '|' + rec.path;
1086
+ if (seen[key]) { return; }
1087
+ seen[key] = 1;
1088
+ out.push(rec);
1089
+ }
1090
+ try {
1091
+ for (var i = 0; i < blockedLog.length; i++) {
1092
+ var b = blockedLog[i];
1093
+ // Interceptions are kept for the life of the page, but once the
1094
+ // category is granted the element has been revived and is no longer
1095
+ // held back — reporting it as still blocked would be false.
1096
+ if (allowed(b.category)) { continue; }
1097
+ add({ host: b.host, path: b.path, kind: b.kind, category: b.category, origin: b.origin });
1098
+ }
1099
+ } catch (e) { /* noop */ }
1100
+ // Hand-marked tags still waiting for their category.
1101
+ try {
1102
+ // Only what applyConsentToDom() can actually revive. An img is never
1103
+ // revived by the core, so listing hand-marked images here would show a
1104
+ // pending state that never clears after the visitor accepts.
1105
+ qsa('script[type="text/plain"][data-ck], iframe[data-ck][data-src]')
1106
+ .forEach(function (el) {
1107
+ try {
1108
+ if (el.getAttribute('data-ck-restored')) { return; }
1109
+ if (el.getAttribute('data-ck-blocked')) { return; } // already in the registry
1110
+ var tag = String(el.tagName || '').toLowerCase();
1111
+ if (tag === 'iframe' && el.getAttribute('src')) { return; }
1112
+ var src = el.getAttribute('data-src') || el.getAttribute('data-ck-src') || '';
1113
+ if (!src) { return; }
1114
+ var cat = el.getAttribute('data-ck') || categoryForUrl(src);
1115
+ if (allowed(cat)) { return; }
1116
+ var parts = safeUrlParts(src);
1117
+ add({
1118
+ host: parts.host, path: parts.path,
1119
+ kind: tag === 'iframe' ? 'iframe' : 'script',
1120
+ category: cat || null, origin: 'markup'
1121
+ });
1122
+ } catch (e2) { /* noop */ }
1123
+ });
1124
+ } catch (e3) { /* noop */ }
1125
+ return out;
1126
+ }
1021
1127
  };
1022
1128
 
1023
1129
  // ---------------------------------------------------------------------------
@@ -0,0 +1,134 @@
1
+ /*!
2
+ * ConsentKit debug loader — the activation switch for the debug panel.
3
+ *
4
+ * The panel itself (src/ck-debug.js) is ~30 KB and useful to exactly one
5
+ * person on the page: the site owner, once, while diagnosing something. This
6
+ * loader ships instead of it in the inline blocks and in the WordPress plugin,
7
+ * and fetches the panel only when the visitor asks for it. Without activation
8
+ * it creates no DOM, installs no observers and makes no network request.
9
+ *
10
+ * Activation (?ck_debug=1 / #ck_debug / localStorage) and the three-step
11
+ * resolution order for the panel URL are documented in README «Debug mode».
12
+ * Two things are worth knowing while reading this file:
13
+ *
14
+ * - The off switch has to work even though the panel is not loaded, so the
15
+ * loader — not the panel — owns the stored flag. ck-debug.js keeps its own
16
+ * identical copy of parseActivation() because it is loaded directly on our
17
+ * demo page and must stay self-contained; writing the flag twice is
18
+ * idempotent, and test/debug-loader.test.mjs asserts the two agree.
19
+ * - Nothing here runs before activation. No DOM, no request, no observers.
20
+ *
21
+ * Load order: ck-core.js -> ck-locales.js -> ck-ui.js -> [ck-saas.js] -> this.
22
+ *
23
+ * Copyright (c) 2026 E-COM CONSULT PLUS. MIT License — see LICENSE.
24
+ */
25
+ (function (global) {
26
+ 'use strict';
27
+
28
+ if (!global || typeof global !== 'object') { return; }
29
+
30
+ var LS_KEY = 'ck_debug';
31
+ var CDN = 'https://cdn.jsdelivr.net/npm/@ecomconsult/consentkit@';
32
+
33
+ function isOffValue(v) {
34
+ return v === '0' || v === 'false' || v === 'no' || v === '';
35
+ }
36
+
37
+ // Identical to src/ck-debug.js parseActivation(). Pure: no DOM, no storage.
38
+ function parseActivation(search, hash, stored) {
39
+ var on = null;
40
+
41
+ var q = String(search || '');
42
+ if (q.charAt(0) === '?') { q = q.slice(1); }
43
+ var pairs = q ? q.split('&') : [];
44
+ for (var i = 0; i < pairs.length; i++) {
45
+ var eq = pairs[i].indexOf('=');
46
+ var k = eq === -1 ? pairs[i] : pairs[i].slice(0, eq);
47
+ if (k !== LS_KEY) { continue; }
48
+ on = eq === -1 ? true : !isOffValue(pairs[i].slice(eq + 1));
49
+ }
50
+
51
+ if (on === null) {
52
+ var h = String(hash || '');
53
+ if (h.charAt(0) === '#') { h = h.slice(1); }
54
+ if (h === LS_KEY) { on = true; }
55
+ else if (h.indexOf(LS_KEY + '=') === 0) { on = !isOffValue(h.slice(LS_KEY.length + 1)); }
56
+ }
57
+
58
+ if (on === null) {
59
+ return { active: String(stored || '') === '1', persist: null };
60
+ }
61
+ return { active: on, persist: on ? 'on' : 'off' };
62
+ }
63
+
64
+ // Pure: takes what the page happens to expose, returns the URL to load.
65
+ // `explicit` is window.ConsentKitDebugUrl, `saas` is ConsentKit._saas.
66
+ // Order: explicit URL -> SaaS API origin -> jsDelivr pinned to the running
67
+ // core version. See README «Debug mode».
68
+ function resolveUrl(explicit, saas, version) {
69
+ var pinned = String(explicit || '').trim();
70
+ if (pinned) { return pinned; }
71
+
72
+ if (saas && saas.siteId && saas.api) {
73
+ var base = String(saas.api).replace(/\/+$/, '');
74
+ if (base) { return base + '/client/ck-debug.js'; }
75
+ }
76
+
77
+ return CDN + encodeURIComponent(String(version || 'latest')) + '/src/ck-debug.js';
78
+ }
79
+
80
+ var API = {
81
+ parseActivation: parseActivation,
82
+ resolveUrl: resolveUrl,
83
+ active: false,
84
+ url: null
85
+ };
86
+ try {
87
+ if (global.ConsentKit) { global.ConsentKit._debugLoader = API; }
88
+ global.__ckDebugLoader = API;
89
+ } catch (e) { /* noop */ }
90
+
91
+ function lsGet(k) {
92
+ try { return global.localStorage ? global.localStorage.getItem(k) : null; } catch (e) { return null; }
93
+ }
94
+ function lsSet(k, v) {
95
+ try { if (global.localStorage) { global.localStorage.setItem(k, v); } } catch (e) { /* noop */ }
96
+ }
97
+ function lsDel(k) {
98
+ try { if (global.localStorage) { global.localStorage.removeItem(k); } } catch (e) { /* noop */ }
99
+ }
100
+
101
+ var loc = global.location;
102
+ var act = parseActivation(loc && loc.search, loc && loc.hash, lsGet(LS_KEY));
103
+ // Persist BEFORE the early return: ?ck_debug=0 has to clear the stored flag
104
+ // even though nothing is loaded afterwards, or the panel could never be
105
+ // switched off again once it had been switched on.
106
+ if (act.persist === 'on') { lsSet(LS_KEY, '1'); }
107
+ if (act.persist === 'off') { lsDel(LS_KEY); }
108
+ if (!act.active) { return; }
109
+
110
+ var doc = global.document;
111
+ if (!doc || typeof doc.createElement !== 'function') { return; }
112
+
113
+ // Already loaded (the panel published its surface, or this ran twice).
114
+ if (global.__ckDebug) { API.active = true; return; }
115
+
116
+ var CK = global.ConsentKit;
117
+ var url = resolveUrl(
118
+ global.ConsentKitDebugUrl,
119
+ CK && CK._saas,
120
+ (CK && CK.version) || ''
121
+ );
122
+ API.active = true;
123
+ API.url = url;
124
+
125
+ try {
126
+ var s = doc.createElement('script');
127
+ s.src = url;
128
+ s.async = true;
129
+ // No opt-out attribute is needed: the blocking engine matches a fixed list
130
+ // of tracker hosts, and neither jsDelivr nor a ConsentKit API host is on
131
+ // it — test/debug-loader.test.mjs asserts that.
132
+ (doc.head || doc.body || doc.documentElement).appendChild(s);
133
+ } catch (e) { /* noop */ }
134
+ })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));
@@ -0,0 +1,847 @@
1
+ /*!
2
+ * ConsentKit debug panel (opt-in) — shows what the banner actually did on this
3
+ * page. Off by default: without activation this file creates no DOM, installs
4
+ * no observers and touches nothing.
5
+ *
6
+ * Activation (this browser only, nothing is sent anywhere):
7
+ * ?ck_debug=1 or #ck_debug — turns it on and remembers it (localStorage)
8
+ * ?ck_debug=0 — turns it off and forgets it
9
+ * localStorage.ck_debug = '1' — same as the query parameter
10
+ *
11
+ * Load order: ck-core.js -> ck-locales.js -> ck-ui.js -> [ck-saas.js] -> ck-debug.js
12
+ *
13
+ * Copyright (c) 2026 E-COM CONSULT PLUS. MIT License — see LICENSE.
14
+ */
15
+ (function (global) {
16
+ 'use strict';
17
+
18
+ if (!global || typeof global !== 'object') { return; }
19
+
20
+ var LS_KEY = 'ck_debug';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Activation (pure, testable: no DOM, no storage)
24
+ // ---------------------------------------------------------------------------
25
+ // Returns { active, persist } where persist is 'on' | 'off' | null:
26
+ // the query/hash form is sticky so the panel survives navigation, the bare
27
+ // localStorage form changes nothing.
28
+ function isOffValue(v) {
29
+ return v === '0' || v === 'false' || v === 'no' || v === '';
30
+ }
31
+
32
+ function parseActivation(search, hash, stored) {
33
+ var on = null;
34
+
35
+ var q = String(search || '');
36
+ if (q.charAt(0) === '?') { q = q.slice(1); }
37
+ var pairs = q ? q.split('&') : [];
38
+ for (var i = 0; i < pairs.length; i++) {
39
+ var eq = pairs[i].indexOf('=');
40
+ var k = eq === -1 ? pairs[i] : pairs[i].slice(0, eq);
41
+ if (k !== LS_KEY) { continue; }
42
+ // ?ck_debug (no "="), ?ck_debug=1, =true, =yes -> on
43
+ // ?ck_debug=0, =false, =no, =(empty) -> off. The empty value is treated
44
+ // as off deliberately: a form or a link builder that drops the value is
45
+ // far more likely to mean "not set" than "switch the panel on".
46
+ on = eq === -1 ? true : !isOffValue(pairs[i].slice(eq + 1));
47
+ }
48
+
49
+ if (on === null) {
50
+ var h = String(hash || '');
51
+ if (h.charAt(0) === '#') { h = h.slice(1); }
52
+ // #ck_debug or #ck_debug=1 (a plain fragment id, not a query)
53
+ if (h === LS_KEY) { on = true; }
54
+ else if (h.indexOf(LS_KEY + '=') === 0) { on = !isOffValue(h.slice(LS_KEY.length + 1)); }
55
+ }
56
+
57
+ if (on === null) {
58
+ return { active: String(stored || '') === '1', persist: null };
59
+ }
60
+ return { active: on, persist: on ? 'on' : 'off' };
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Report (pure, testable: takes plain data, returns plain data)
65
+ // ---------------------------------------------------------------------------
66
+ // No PII by construction: cookie names only (never values), host + path only
67
+ // (never query strings — tracker URLs carry ids there).
68
+ function stripUrl(url) {
69
+ var s = String(url || '');
70
+ var host = '';
71
+ var path = '';
72
+ var m = /^(?:[a-z]+:)?\/\/([^/?#]+)([^?#]*)/i.exec(s);
73
+ if (m) {
74
+ host = m[1].toLowerCase().replace(/:\d+$/, '');
75
+ path = m[2] || '';
76
+ } else {
77
+ path = s.split('?')[0].split('#')[0];
78
+ }
79
+ return { host: host, path: path };
80
+ }
81
+
82
+ // entries: PerformanceResourceTiming-like [{ name, startTime, initiatorType }]
83
+ // consentAtMs: performance-clock ms of the decision, or null when undecided
84
+ // classify: url -> category | null (ConsentKit._categoryForUrl)
85
+ function buildRequests(entries, consentAtMs, classify) {
86
+ var out = [];
87
+ var list = entries || [];
88
+ var seen = {};
89
+ for (var i = 0; i < list.length; i++) {
90
+ var e = list[i];
91
+ if (!e || !e.name) { continue; }
92
+ var cat = null;
93
+ try { cat = classify ? classify(e.name) : null; } catch (err) { cat = null; }
94
+ if (!cat) { continue; } // only known trackers
95
+ var p = stripUrl(e.name);
96
+ var t = Number(e.startTime) || 0;
97
+ var when = consentAtMs === null || consentAtMs === undefined
98
+ ? 'before' : (t >= consentAtMs ? 'after' : 'before');
99
+ var key = p.host + '|' + p.path + '|' + when;
100
+ if (seen[key]) { seen[key].count++; continue; }
101
+ var rec = {
102
+ host: p.host, path: p.path, category: cat, when: when,
103
+ at: Math.round(t), kind: String((e && e.initiatorType) || ''), count: 1
104
+ };
105
+ seen[key] = rec;
106
+ out.push(rec);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function buildReport(input) {
112
+ var d = input || {};
113
+ var st = d.state || {};
114
+ var cats = st.categories || {};
115
+ return {
116
+ generatedAt: d.now || null,
117
+ client: {
118
+ version: d.version || null,
119
+ source: d.siteId ? 'saas' : 'inline',
120
+ siteId: d.siteId || null,
121
+ policyVersion: st.policyVersion || (d.config && d.config.policyVersion) || null,
122
+ etag: d.etag || null
123
+ },
124
+ consent: {
125
+ status: !st.decided ? 'none'
126
+ : (cats.functional && cats.analytics && cats.marketing) ? 'accepted'
127
+ : (!cats.functional && !cats.analytics && !cats.marketing) ? 'rejected'
128
+ : 'partial',
129
+ categories: {
130
+ necessary: true,
131
+ functional: !!cats.functional,
132
+ analytics: !!cats.analytics,
133
+ marketing: !!cats.marketing
134
+ },
135
+ decidedAt: st.ts || null,
136
+ method: st.method || null,
137
+ ttlDays: d.ttlDays == null ? null : Number(d.ttlDays)
138
+ },
139
+ blocked: (d.blocked || []).map(function (b) {
140
+ return { host: b.host, path: b.path, kind: b.kind, category: b.category, origin: b.origin };
141
+ }),
142
+ requests: buildRequests(d.entries, d.consentAtMs, d.classify),
143
+ consentMode: (d.consentMode || []).slice(),
144
+ // Names only — a consent debug panel must never leak cookie contents.
145
+ cookieNames: (d.cookieNames || []).slice(),
146
+ note: NOTE_EN
147
+ };
148
+ }
149
+
150
+ var NOTE_RU = 'Запросы, ушедшие до загрузки ConsentKit (обычный <script src> ' +
151
+ 'в разметке), видны в этом списке, но заблокировать их клиент не может — ' +
152
+ 'такие теги размечают вручную.';
153
+ var NOTE_EN = 'Requests that left before ConsentKit loaded (a plain <script src> ' +
154
+ 'written into the HTML) show up here but cannot be blocked — mark such tags up manually.';
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Panel language (pure; the JSON report stays language-neutral either way)
158
+ // ---------------------------------------------------------------------------
159
+ // The panel is read by whoever is debugging the site, so it follows the same
160
+ // language the banner resolved for this visitor rather than a build flag.
161
+ // Only ru and en exist: this is an internal diagnostic surface, and a
162
+ // half-translated one is worse than an English one.
163
+ var STRINGS = {
164
+ ru: {
165
+ regionLabel: 'ConsentKit — режим отладки',
166
+ collapse: 'Свернуть',
167
+ expand: 'Развернуть',
168
+ closeLabel: 'Закрыть и выключить режим отладки',
169
+ secClient: 'Клиент',
170
+ version: 'версия',
171
+ source: 'источник',
172
+ srcSaas: 'SaaS',
173
+ srcInline: 'инлайн',
174
+ secConsent: 'Согласие',
175
+ decidedAt: 'решение',
176
+ method: 'способ',
177
+ ttl: 'срок cookie',
178
+ days: ' дн.',
179
+ status: { none: 'нет решения', accepted: 'принято', rejected: 'отклонено', partial: 'частично' },
180
+ secBlocked: 'Заблокировано до согласия',
181
+ noBlocked: 'ничего не перехвачено',
182
+ markup: ' (разметка)',
183
+ secRequests: 'Запросы к трекерам',
184
+ noRequests: 'запросов к известным трекерам не было',
185
+ after: 'после согласия',
186
+ before: 'до согласия',
187
+ ms: ' мс',
188
+ note: NOTE_RU,
189
+ secConsentMode: 'Consent Mode / dataLayer',
190
+ noEvents: 'событий не было',
191
+ secActions: 'Действия',
192
+ reset: 'Сбросить согласие',
193
+ showPrefs: 'Показать настройки',
194
+ copy: 'Скопировать отчёт',
195
+ copied: 'Скопировано',
196
+ copyFailed: 'Не вышло',
197
+ footer: 'Панель видна только в этом браузере. Выключить: добавьте ?ck_debug=0 к адресу.'
198
+ },
199
+ en: {
200
+ regionLabel: 'ConsentKit — debug mode',
201
+ collapse: 'Collapse',
202
+ expand: 'Expand',
203
+ closeLabel: 'Close and turn debug mode off',
204
+ secClient: 'Client',
205
+ version: 'version',
206
+ source: 'source',
207
+ srcSaas: 'SaaS',
208
+ srcInline: 'inline',
209
+ secConsent: 'Consent',
210
+ decidedAt: 'decided',
211
+ method: 'method',
212
+ ttl: 'cookie lifetime',
213
+ days: ' days',
214
+ status: { none: 'no decision', accepted: 'accepted', rejected: 'rejected', partial: 'partial' },
215
+ secBlocked: 'Blocked until consent',
216
+ noBlocked: 'nothing intercepted',
217
+ markup: ' (markup)',
218
+ secRequests: 'Tracker requests',
219
+ noRequests: 'no requests to known trackers',
220
+ after: 'after consent',
221
+ before: 'before consent',
222
+ ms: ' ms',
223
+ note: NOTE_EN,
224
+ secConsentMode: 'Consent Mode / dataLayer',
225
+ noEvents: 'no events',
226
+ secActions: 'Actions',
227
+ reset: 'Reset consent',
228
+ showPrefs: 'Show preferences',
229
+ copy: 'Copy report',
230
+ copied: 'Copied',
231
+ copyFailed: 'Failed',
232
+ footer: 'This panel is visible in this browser only. To turn it off, add ?ck_debug=0 to the URL.'
233
+ }
234
+ };
235
+
236
+ // banner language (ConsentKit config) -> navigator.language -> en.
237
+ // `cfgLang` is ConsentKit.config.language, which may be 'auto'.
238
+ function pickLang(cfgLang, navLang) {
239
+ var raw = String(cfgLang || '').toLowerCase();
240
+ if (!raw || raw === 'auto') { raw = String(navLang || '').toLowerCase(); }
241
+ return raw.slice(0, 2) === 'ru' ? 'ru' : 'en';
242
+ }
243
+
244
+ // Testable surface. Published before the activation check so the inactive
245
+ // path is testable too; it is not a public API.
246
+ var API = {
247
+ parseActivation: parseActivation,
248
+ buildReport: buildReport,
249
+ buildRequests: buildRequests,
250
+ stripUrl: stripUrl,
251
+ pickLang: pickLang,
252
+ strings: STRINGS,
253
+ active: false
254
+ };
255
+ try {
256
+ if (global.ConsentKit) { global.ConsentKit._debug = API; }
257
+ global.__ckDebug = API;
258
+ } catch (e) { /* noop */ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Everything below runs only when activated.
262
+ // ---------------------------------------------------------------------------
263
+ var doc = global.document;
264
+ var loc = global.location;
265
+
266
+ function lsGet(k) {
267
+ try { return global.localStorage ? global.localStorage.getItem(k) : null; } catch (e) { return null; }
268
+ }
269
+ function lsSet(k, v) {
270
+ try { if (global.localStorage) { global.localStorage.setItem(k, v); } } catch (e) { /* noop */ }
271
+ }
272
+ function lsDel(k) {
273
+ try { if (global.localStorage) { global.localStorage.removeItem(k); } } catch (e) { /* noop */ }
274
+ }
275
+
276
+ var act = parseActivation(loc && loc.search, loc && loc.hash, lsGet(LS_KEY));
277
+ if (act.persist === 'on') { lsSet(LS_KEY, '1'); }
278
+ if (act.persist === 'off') { lsDel(LS_KEY); }
279
+ if (!act.active) { return; }
280
+ if (!doc || typeof doc.createElement !== 'function') { return; }
281
+ API.active = true;
282
+
283
+ var CK = global.ConsentKit;
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Observation (installed only when active)
287
+ // ---------------------------------------------------------------------------
288
+ var resources = [];
289
+ var consentAtMs = null; // performance clock, set when consent is given
290
+ var consentMode = []; // last ck_* dataLayer events and gtag consent calls
291
+ var CM_MAX = 20;
292
+
293
+ function nowMs() {
294
+ try {
295
+ if (global.performance && typeof global.performance.now === 'function') {
296
+ return global.performance.now();
297
+ }
298
+ } catch (e) { /* noop */ }
299
+ return 0;
300
+ }
301
+
302
+ // A decision restored from a previous page load happened before this page
303
+ // started, so every request on this load counts as "after".
304
+ function seedConsentTime() {
305
+ try {
306
+ var st = CK && CK.getState ? CK.getState() : null;
307
+ if (st && st.decided && consentAtMs === null) { consentAtMs = 0; }
308
+ } catch (e) { /* noop */ }
309
+ }
310
+
311
+ // Stamp the moment the visitor decides, BEFORE the core acts on it.
312
+ //
313
+ // This cannot wait for ck:change: accept() revives the blocked scripts
314
+ // synchronously and dispatches ck:change afterwards, so by the time the event
315
+ // fires those scripts have already issued their requests. Stamping on the
316
+ // event would then place them *before* the cutoff and the panel would report
317
+ // "до согласия" for the very requests the consent just released — exactly
318
+ // backwards, and on the one line an owner reads most carefully.
319
+ //
320
+ // Wrapping is observe-only: the original method is called with the original
321
+ // arguments and its return value passed straight back.
322
+ function stampOnDecision() {
323
+ try {
324
+ if (!CK) { return; }
325
+ ['accept', 'rejectAll'].forEach(function (name) {
326
+ var orig = CK[name];
327
+ if (typeof orig !== 'function' || orig.__ckDebugWrapped) { return; }
328
+ var wrapped = function () {
329
+ try { consentAtMs = nowMs(); } catch (e) { /* noop */ }
330
+ return orig.apply(CK, arguments);
331
+ };
332
+ wrapped.__ckDebugWrapped = true;
333
+ CK[name] = wrapped;
334
+ });
335
+ // withdraw() puts the page back to "no decision yet".
336
+ var w = CK.withdraw;
337
+ if (typeof w === 'function' && !w.__ckDebugWrapped) {
338
+ var wrappedW = function () {
339
+ try { consentAtMs = null; } catch (e) { /* noop */ }
340
+ return w.apply(CK, arguments);
341
+ };
342
+ wrappedW.__ckDebugWrapped = true;
343
+ CK.withdraw = wrappedW;
344
+ }
345
+ } catch (e) { /* noop */ }
346
+ }
347
+
348
+ function collectResources() {
349
+ try {
350
+ var perf = global.performance;
351
+ if (!perf) { return; }
352
+ if (typeof perf.getEntriesByType === 'function') {
353
+ var buffered = perf.getEntriesByType('resource') || [];
354
+ for (var i = 0; i < buffered.length; i++) { resources.push(buffered[i]); }
355
+ }
356
+ if (typeof global.PerformanceObserver === 'function') {
357
+ var po = new global.PerformanceObserver(function (list) {
358
+ try {
359
+ var got = list.getEntries() || [];
360
+ for (var j = 0; j < got.length; j++) { resources.push(got[j]); }
361
+ schedule();
362
+ } catch (e2) { /* noop */ }
363
+ });
364
+ // buffered:true re-delivers what happened before we attached; harmless
365
+ // duplicates are deduped when the list is rendered.
366
+ try { po.observe({ type: 'resource', buffered: true }); }
367
+ catch (e3) { try { po.observe({ entryTypes: ['resource'] }); } catch (e4) { /* noop */ } }
368
+ }
369
+ } catch (e) { /* noop */ }
370
+ }
371
+
372
+ // Observe only: the existing dataLayer.push is called first and its return
373
+ // value passed through, so a GTM/other wrapper keeps working.
374
+ function watchDataLayer() {
375
+ try {
376
+ var dl = global.dataLayer;
377
+ if (!dl) { global.dataLayer = dl = []; }
378
+ if (typeof dl.push !== 'function' || dl.__ckDebugWatched) { return; }
379
+ // Whatever is already in the queue counts too.
380
+ for (var i = 0; i < dl.length; i++) { noteDataLayer(dl[i]); }
381
+ var prev = dl.push;
382
+ dl.push = function () {
383
+ try {
384
+ for (var j = 0; j < arguments.length; j++) { noteDataLayer(arguments[j]); }
385
+ } catch (e) { /* never break the host page */ }
386
+ return prev.apply(this, arguments);
387
+ };
388
+ dl.__ckDebugWatched = true;
389
+ } catch (e) { /* noop */ }
390
+ }
391
+
392
+ function noteDataLayer(arg) {
393
+ try {
394
+ var rec = null;
395
+ // gtag() pushes an arguments object: ['consent', 'default'|'update', {...}]
396
+ if (arg && typeof arg === 'object' && typeof arg.length === 'number' && arg[0] === 'consent') {
397
+ var signals = {};
398
+ var payload = arg[2];
399
+ if (payload && typeof payload === 'object') {
400
+ for (var k in payload) {
401
+ if (Object.prototype.hasOwnProperty.call(payload, k)) { signals[k] = payload[k]; }
402
+ }
403
+ }
404
+ rec = { type: 'gtag consent ' + String(arg[1] || ''), signals: signals, at: Math.round(nowMs()) };
405
+ } else if (arg && typeof arg === 'object' && typeof arg.event === 'string' &&
406
+ arg.event.indexOf('ck_') === 0) {
407
+ rec = { type: arg.event, signals: null, at: Math.round(nowMs()) };
408
+ }
409
+ if (!rec) { return; }
410
+ consentMode.push(rec);
411
+ if (consentMode.length > CM_MAX) { consentMode.shift(); }
412
+ schedule();
413
+ } catch (e) { /* noop */ }
414
+ }
415
+
416
+ function cookieNames() {
417
+ var names = [];
418
+ try {
419
+ var raw = typeof doc.cookie === 'string' ? doc.cookie : '';
420
+ var parts = raw.split(';');
421
+ for (var i = 0; i < parts.length; i++) {
422
+ var t = parts[i].trim();
423
+ if (!t) { continue; }
424
+ var eq = t.indexOf('=');
425
+ names.push(eq === -1 ? t : t.slice(0, eq)); // name only, never the value
426
+ }
427
+ } catch (e) { /* noop */ }
428
+ return names;
429
+ }
430
+
431
+ function saasInfo() {
432
+ var out = { siteId: null, etag: null };
433
+ try {
434
+ if (CK && CK._saas) {
435
+ out.siteId = CK._saas.siteId || null;
436
+ var cached = null;
437
+ try {
438
+ var raw = lsGet('ck_cfg_' + out.siteId);
439
+ cached = raw ? JSON.parse(raw) : null;
440
+ } catch (e2) { cached = null; }
441
+ out.etag = cached && cached.etag ? cached.etag : null;
442
+ }
443
+ } catch (e) { /* noop */ }
444
+ return out;
445
+ }
446
+
447
+ function reportInput() {
448
+ var s = saasInfo();
449
+ var cfg = (CK && CK.config) || {};
450
+ return {
451
+ now: new Date().toISOString(),
452
+ version: CK ? CK.version : null,
453
+ state: CK && CK.getState ? CK.getState() : {},
454
+ config: cfg,
455
+ siteId: s.siteId,
456
+ etag: s.etag,
457
+ ttlDays: cfg.consentTtlDays == null ? null : cfg.consentTtlDays,
458
+ blocked: (CK && typeof CK._blocked === 'function') ? CK._blocked() : [],
459
+ entries: resources,
460
+ consentAtMs: consentAtMs,
461
+ classify: CK ? CK._categoryForUrl : null,
462
+ consentMode: consentMode,
463
+ cookieNames: cookieNames()
464
+ };
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Panel (own Shadow DOM host, own styles)
469
+ // ---------------------------------------------------------------------------
470
+ var CSS = [
471
+ ':host{all:initial;position:fixed;right:12px;bottom:12px;z-index:2147483646;',
472
+ 'font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}',
473
+ '*{box-sizing:border-box}',
474
+ '.w{width:min(360px,calc(100vw - 24px));background:#12151c;color:#dfe3ea;',
475
+ 'border:1px solid #2b3040;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.4);overflow:hidden}',
476
+ '.hd{display:flex;align-items:center;gap:8px;padding:7px 9px;background:#1a1f2a;',
477
+ 'border-bottom:1px solid #2b3040}',
478
+ '.hd b{font-weight:600;color:#fff;font-size:12px}',
479
+ '.hd .sp{flex:1}',
480
+ 'button{font:inherit;color:#dfe3ea;background:#232936;border:1px solid #39415400;',
481
+ 'border-color:#394154;border-radius:5px;padding:3px 8px;cursor:pointer}',
482
+ 'button:hover{background:#2c3444}',
483
+ 'button:focus-visible{outline:2px solid #7aa2ff;outline-offset:1px}',
484
+ '.bd{max-height:min(60vh,460px);overflow:auto;padding:2px 9px 9px}',
485
+ '.bd[hidden]{display:none}',
486
+ 'section{border-top:1px solid #232936;padding:7px 0}',
487
+ 'section:first-child{border-top:0}',
488
+ 'h2{margin:0 0 4px;font-size:11px;font-weight:600;color:#8d96ab;text-transform:uppercase;',
489
+ 'letter-spacing:.04em}',
490
+ 'dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:1px 8px}',
491
+ 'dt{color:#8d96ab}',
492
+ 'dd{margin:0;overflow-wrap:anywhere}',
493
+ 'ul{margin:0;padding:0;list-style:none}',
494
+ 'li{padding:2px 0;border-top:1px dotted #262c3a;overflow-wrap:anywhere}',
495
+ 'li:first-child{border-top:0}',
496
+ '.t{display:inline-block;padding:0 5px;border-radius:3px;font-size:10px;',
497
+ 'background:#2a3142;color:#a9b3c9;margin-right:4px}',
498
+ '.t.on{background:#173a24;color:#7ee2a0}',
499
+ '.t.off{background:#3a1a1d;color:#ff9aa2}',
500
+ '.mut{color:#8d96ab}',
501
+ '.note{margin:6px 0 0;color:#8d96ab;font-size:11px;line-height:1.4}',
502
+ '.row{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}',
503
+ '@media (prefers-reduced-motion:no-preference){button{transition:background-color .12s ease}}'
504
+ ].join('');
505
+
506
+ var host = null, root = null, body = null, toggleBtn = null, collapsed = false;
507
+ var frame = 0;
508
+
509
+ function el(tag, attrs, text) {
510
+ var n = doc.createElement(tag);
511
+ if (attrs) {
512
+ for (var k in attrs) {
513
+ if (Object.prototype.hasOwnProperty.call(attrs, k)) { n.setAttribute(k, attrs[k]); }
514
+ }
515
+ }
516
+ if (text != null) { n.textContent = String(text); }
517
+ return n;
518
+ }
519
+
520
+ function section(title) {
521
+ var s = doc.createElement('section');
522
+ s.appendChild(el('h2', null, title));
523
+ return s;
524
+ }
525
+
526
+ function defs(pairs) {
527
+ var dl = doc.createElement('dl');
528
+ for (var i = 0; i < pairs.length; i++) {
529
+ dl.appendChild(el('dt', null, pairs[i][0]));
530
+ dl.appendChild(el('dd', null, pairs[i][1] == null || pairs[i][1] === '' ? '—' : pairs[i][1]));
531
+ }
532
+ return dl;
533
+ }
534
+
535
+ function tag(text, cls) { return el('span', { class: 't' + (cls ? ' ' + cls : '') }, text); }
536
+
537
+ // Resolved lazily, not at parse time: this file runs before ConsentKit.init()
538
+ // has merged the site's config, so asking for the language now would always
539
+ // read the built-in default. Re-resolved on every render so a page that
540
+ // switches language at runtime switches the panel too.
541
+ var T = STRINGS.en;
542
+ function refreshLang() {
543
+ var nav = global.navigator;
544
+ T = STRINGS[pickLang(
545
+ (CK && CK.config && CK.config.language) || '',
546
+ (nav && (nav.language || nav.userLanguage)) || ''
547
+ )] || STRINGS.en;
548
+ return T;
549
+ }
550
+
551
+ function render() {
552
+ if (!body) { return; }
553
+ refreshLang();
554
+ var r = buildReport(reportInput());
555
+ body.textContent = '';
556
+
557
+ // 1. Client
558
+ var s1 = section(T.secClient);
559
+ s1.appendChild(defs([
560
+ [T.version, r.client.version],
561
+ [T.source, r.client.source === 'saas' ? T.srcSaas : T.srcInline],
562
+ ['siteId', r.client.siteId],
563
+ ['policyVersion', r.client.policyVersion],
564
+ ['ETag', r.client.etag]
565
+ ]));
566
+ body.appendChild(s1);
567
+
568
+ // 2. Consent
569
+ var s2 = section(T.secConsent);
570
+ var line = el('div');
571
+ line.appendChild(tag(T.status[r.consent.status] || r.consent.status,
572
+ r.consent.status === 'accepted' ? 'on' : r.consent.status === 'rejected' ? 'off' : ''));
573
+ s2.appendChild(line);
574
+ var cl = doc.createElement('div');
575
+ ['necessary', 'functional', 'analytics', 'marketing'].forEach(function (c) {
576
+ cl.appendChild(tag((r.consent.categories[c] ? '✓ ' : '✗ ') + c,
577
+ r.consent.categories[c] ? 'on' : 'off'));
578
+ });
579
+ s2.appendChild(cl);
580
+ s2.appendChild(defs([
581
+ [T.decidedAt, r.consent.decidedAt],
582
+ [T.method, r.consent.method],
583
+ [T.ttl, r.consent.ttlDays == null ? null : r.consent.ttlDays + T.days]
584
+ ]));
585
+ body.appendChild(s2);
586
+
587
+ // 3. Blocked until consent
588
+ var s3 = section(T.secBlocked + ' (' + r.blocked.length + ')');
589
+ if (!r.blocked.length) {
590
+ s3.appendChild(el('div', { class: 'mut' }, T.noBlocked));
591
+ } else {
592
+ var u3 = doc.createElement('ul');
593
+ r.blocked.forEach(function (b) {
594
+ var li = doc.createElement('li');
595
+ li.appendChild(tag(b.kind));
596
+ li.appendChild(tag(b.category || '?'));
597
+ li.appendChild(doc.createTextNode(b.host + b.path +
598
+ (b.origin === 'markup' ? T.markup : '')));
599
+ u3.appendChild(li);
600
+ });
601
+ s3.appendChild(u3);
602
+ }
603
+ body.appendChild(s3);
604
+
605
+ // 4. Tracker requests
606
+ var s4 = section(T.secRequests + ' (' + r.requests.length + ')');
607
+ if (!r.requests.length) {
608
+ s4.appendChild(el('div', { class: 'mut' }, T.noRequests));
609
+ } else {
610
+ var u4 = doc.createElement('ul');
611
+ r.requests.forEach(function (q) {
612
+ var li = doc.createElement('li');
613
+ li.appendChild(tag(q.when === 'after' ? T.after : T.before,
614
+ q.when === 'after' ? 'on' : 'off'));
615
+ li.appendChild(tag(q.category));
616
+ li.appendChild(doc.createTextNode(q.host + q.path + ' · ' + q.at + T.ms +
617
+ (q.count > 1 ? ' ×' + q.count : '')));
618
+ u4.appendChild(li);
619
+ });
620
+ s4.appendChild(u4);
621
+ }
622
+ s4.appendChild(el('p', { class: 'note' }, T.note));
623
+ body.appendChild(s4);
624
+
625
+ // 5. Consent Mode
626
+ var s5 = section(T.secConsentMode + ' (' + r.consentMode.length + ')');
627
+ if (!r.consentMode.length) {
628
+ s5.appendChild(el('div', { class: 'mut' }, T.noEvents));
629
+ } else {
630
+ var u5 = doc.createElement('ul');
631
+ r.consentMode.slice().reverse().forEach(function (c) {
632
+ var li = doc.createElement('li');
633
+ li.appendChild(tag(c.at + T.ms));
634
+ var txt = c.type;
635
+ if (c.signals) {
636
+ var bits = [];
637
+ for (var k in c.signals) {
638
+ if (Object.prototype.hasOwnProperty.call(c.signals, k)) {
639
+ bits.push(k + '=' + c.signals[k]);
640
+ }
641
+ }
642
+ if (bits.length) { txt += ': ' + bits.join(', '); }
643
+ }
644
+ li.appendChild(doc.createTextNode(txt));
645
+ u5.appendChild(li);
646
+ });
647
+ s5.appendChild(u5);
648
+ }
649
+ body.appendChild(s5);
650
+
651
+ // 6. Buttons
652
+ var s6 = section(T.secActions);
653
+ var row = el('div', { class: 'row' });
654
+ var bReset = el('button', { type: 'button' }, T.reset);
655
+ bReset.addEventListener('click', resetConsent);
656
+ var bShow = el('button', { type: 'button' }, T.showPrefs);
657
+ bShow.addEventListener('click', showBanner);
658
+ var bCopy = el('button', { type: 'button' }, T.copy);
659
+ bCopy.addEventListener('click', function () { copyReport(bCopy); });
660
+ row.appendChild(bReset); row.appendChild(bShow); row.appendChild(bCopy);
661
+ s6.appendChild(row);
662
+ s6.appendChild(el('p', { class: 'note' }, T.footer));
663
+ body.appendChild(s6);
664
+ }
665
+
666
+ function schedule() {
667
+ if (frame) { return; }
668
+ frame = 1;
669
+ var run = function () { frame = 0; try { render(); } catch (e) { /* noop */ } };
670
+ try {
671
+ if (typeof global.requestAnimationFrame === 'function') { global.requestAnimationFrame(run); }
672
+ else { global.setTimeout(run, 50); }
673
+ } catch (e) { run(); }
674
+ }
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // Buttons
678
+ // ---------------------------------------------------------------------------
679
+ function resetConsent() {
680
+ try { if (CK && CK.withdraw) { CK.withdraw(); } } catch (e) { /* noop */ }
681
+ // withdraw() already clears the record; belt and braces for a page whose
682
+ // cookie was written on a parent domain.
683
+ try {
684
+ var h = global.location && global.location.hostname ? global.location.hostname : '';
685
+ var variants = ['', h ? '; domain=' + h : '', h ? '; domain=.' + h : ''];
686
+ var labels = h ? h.split('.') : [];
687
+ if (labels.length > 2) { variants.push('; domain=.' + labels.slice(-2).join('.')); }
688
+ for (var i = 0; i < variants.length; i++) {
689
+ doc.cookie = 'ck_consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/' + variants[i];
690
+ }
691
+ } catch (e) { /* noop */ }
692
+ lsDel('ck_consent');
693
+ try { global.location.reload(); } catch (e) { /* noop */ }
694
+ }
695
+
696
+ function showBanner() {
697
+ // Non-destructive on purpose: this opens the preferences panel and leaves
698
+ // the stored decision alone. Erasing consent is the other button's job, and
699
+ // on a live site a "show banner" click must never wipe the owner's record.
700
+ try { if (CK && CK.show) { CK.show(); } } catch (e) { /* noop */ }
701
+ schedule();
702
+ }
703
+
704
+ function copyReport(btn) {
705
+ var text = '';
706
+ try { text = JSON.stringify(buildReport(reportInput()), null, 2); } catch (e) { text = '{}'; }
707
+ var done = function (ok) {
708
+ try {
709
+ btn.textContent = ok ? T.copied : T.copyFailed;
710
+ global.setTimeout(function () { btn.textContent = T.copy; }, 1500);
711
+ } catch (e2) { /* noop */ }
712
+ };
713
+ try {
714
+ if (global.navigator && global.navigator.clipboard && global.navigator.clipboard.writeText) {
715
+ global.navigator.clipboard.writeText(text).then(function () { done(true); },
716
+ function () { done(fallbackCopy(text)); });
717
+ return;
718
+ }
719
+ } catch (e) { /* noop */ }
720
+ done(fallbackCopy(text));
721
+ }
722
+
723
+ function fallbackCopy(text) {
724
+ try {
725
+ var ta = doc.createElement('textarea');
726
+ ta.value = text;
727
+ ta.setAttribute('style', 'position:fixed;top:-9999px;left:-9999px');
728
+ doc.body.appendChild(ta);
729
+ ta.select();
730
+ var ok = doc.execCommand ? doc.execCommand('copy') : false;
731
+ doc.body.removeChild(ta);
732
+ return !!ok;
733
+ } catch (e) { return false; }
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Mount
738
+ // ---------------------------------------------------------------------------
739
+ // The banner's bar layout sits at bottom:16 across the full width and the UI's
740
+ // floating button at bottom-left; lift the panel above whatever is at the
741
+ // bottom right now so its buttons stay clickable.
742
+ function avoidBanner() {
743
+ if (!host) { return; }
744
+ var bottom = 12;
745
+ try {
746
+ var ckRoot = doc.getElementById('ck-root');
747
+ var sr = ckRoot && ckRoot.shadowRoot;
748
+ // The bar spans the width at bottom:16; the box-right card sits exactly
749
+ // where this panel does. Both must be cleared (the UI's floating button
750
+ // is bottom-left and never collides).
751
+ var banner = sr && sr.querySelector(
752
+ '.ck-banner--bar.ck-pos-bottom, .ck-banner--box.ck-pos-bottom-right');
753
+ if (banner && banner.getBoundingClientRect) {
754
+ var r = banner.getBoundingClientRect();
755
+ if (r.height > 0 && r.bottom > (global.innerHeight || 0) - r.height - 40) {
756
+ bottom = Math.round(r.height) + 24;
757
+ }
758
+ }
759
+ } catch (e) { /* noop */ }
760
+ try { host.style.bottom = bottom + 'px'; } catch (e2) { /* noop */ }
761
+ }
762
+
763
+ function mount() {
764
+ if (host || !doc.body) { return; }
765
+ // A page can carry the loader (inline block) AND a manual <script> tag for
766
+ // the panel: the second copy must not mount a second panel.
767
+ if (doc.querySelector('ck-debug')) { return; }
768
+ refreshLang();
769
+ host = doc.createElement('ck-debug');
770
+ host.setAttribute('aria-live', 'off');
771
+ root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : null;
772
+ if (!root) { host = null; return; }
773
+
774
+ var style = doc.createElement('style');
775
+ style.textContent = CSS;
776
+ root.appendChild(style);
777
+
778
+ var wrap = el('div', { class: 'w', role: 'region', 'aria-label': T.regionLabel });
779
+ var head = el('div', { class: 'hd' });
780
+ head.appendChild(el('b', null, 'ConsentKit debug'));
781
+ head.appendChild(el('span', { class: 'sp' }));
782
+
783
+ toggleBtn = el('button', { type: 'button', 'aria-expanded': 'true' }, T.collapse);
784
+ toggleBtn.addEventListener('click', function () {
785
+ collapsed = !collapsed;
786
+ body.hidden = collapsed;
787
+ toggleBtn.textContent = collapsed ? T.expand : T.collapse;
788
+ toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
789
+ if (!collapsed) { schedule(); }
790
+ });
791
+ head.appendChild(toggleBtn);
792
+
793
+ var closeBtn = el('button', { type: 'button', 'aria-label': T.closeLabel }, '×');
794
+ closeBtn.addEventListener('click', function () {
795
+ lsDel(LS_KEY);
796
+ try { host.parentNode.removeChild(host); } catch (e) { /* noop */ }
797
+ host = null;
798
+ API.active = false;
799
+ });
800
+ head.appendChild(closeBtn);
801
+
802
+ wrap.appendChild(head);
803
+ body = el('div', { class: 'bd' });
804
+ wrap.appendChild(body);
805
+ root.appendChild(wrap);
806
+ doc.body.appendChild(host);
807
+
808
+ render();
809
+ avoidBanner();
810
+ }
811
+
812
+ function boot() {
813
+ seedConsentTime();
814
+ stampOnDecision();
815
+ collectResources();
816
+ watchDataLayer();
817
+ mount();
818
+
819
+ try {
820
+ doc.addEventListener('ck:change', function () {
821
+ // Fallback for a decision made through some other path than
822
+ // accept()/rejectAll() (a host page calling into the core directly).
823
+ // stampOnDecision() has usually set this already, and its stamp is the
824
+ // accurate one — do not overwrite it here: entry.startTime is on the
825
+ // performance clock, and by now the revived scripts have already run.
826
+ try {
827
+ var st = CK && CK.getState ? CK.getState() : null;
828
+ if (st && st.decided) { if (consentAtMs === null) { consentAtMs = nowMs(); } }
829
+ else { consentAtMs = null; }
830
+ } catch (e2) { /* noop */ }
831
+ schedule();
832
+ global.setTimeout(avoidBanner, 60);
833
+ }, false);
834
+ doc.addEventListener('ck:init', function () { schedule(); global.setTimeout(avoidBanner, 60); }, false);
835
+ } catch (e) { /* noop */ }
836
+
837
+ // Late trackers and revived scripts keep arriving after the first render.
838
+ try { global.setInterval(schedule, 3000); } catch (e) { /* noop */ }
839
+ try { global.addEventListener('resize', avoidBanner, false); } catch (e) { /* noop */ }
840
+ }
841
+
842
+ try {
843
+ if (doc.readyState === 'loading') {
844
+ doc.addEventListener('DOMContentLoaded', function () { try { boot(); } catch (e) { /* noop */ } }, false);
845
+ } else { boot(); }
846
+ } catch (e) { /* noop */ }
847
+ })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));