@ecomconsult/consentkit 0.5.6 → 0.5.8

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/src/ck-debug.js CHANGED
@@ -108,6 +108,92 @@
108
108
  return out;
109
109
  }
110
110
 
111
+ // report `why` -> STRINGS key. A map rather than a switch so the report stays
112
+ // language-neutral: the JSON carries 'early', the panel renders the sentence.
113
+ var WHY_KEY = {
114
+ early: 'whyEarly',
115
+ gcm: 'whyGcm',
116
+ held: 'whyHeld',
117
+ dead: 'whyDead'
118
+ };
119
+
120
+ /* SPEC V1.12 §3 — «каждая строка "до согласия" получает пометку словами».
121
+
122
+ A request that left before the decision is not automatically a problem, and
123
+ the four cases the owner needs told apart are:
124
+
125
+ 'held' — the engine caught it: this is the banner working. The host
126
+ appears in `blocked`, so the request the browser recorded is
127
+ the interception, not a leak.
128
+ 'dead' — caught, the category was later granted, and it still never came
129
+ back: the tag is almost always missing type="text/plain".
130
+ 'gcm' — Consent Mode was already denied when it fired, so the vendor
131
+ was told not to write cookies. Google's own tags do this.
132
+ 'early' — none of the above: the tag ran BEFORE the banner line in the
133
+ markup, so nothing could have held it. This is the one that
134
+ needs a fix on the site, and it is the one this note exists for.
135
+
136
+ Additive: every existing field of a request row is untouched, so a caller
137
+ reading the JSON report keeps working and `why` is simply new. Rows AFTER
138
+ consent get no note — there is nothing to explain about a request the
139
+ visitor agreed to. */
140
+ function explainRequests(requests, blocked, consentMode) {
141
+ // Consent Mode was told 'denied' at page load: our own gcmDefault() pushes
142
+ // exactly that before any tag can run.
143
+ var gcmDenied = false;
144
+ try {
145
+ for (var m = 0; m < consentMode.length; m++) {
146
+ var c = consentMode[m];
147
+ // noteDataLayer() records the gtag call verbatim: 'gtag consent default'.
148
+ if (!c || String(c.type || '').indexOf('consent default') === -1 || !c.signals) { continue; }
149
+ var sig = c.signals;
150
+ gcmDenied = sig.analytics_storage === 'denied' || sig.ad_storage === 'denied';
151
+ break;
152
+ }
153
+ } catch (e) { gcmDenied = false; }
154
+
155
+ // host -> the interception record, so a request can be matched to what the
156
+ // engine did with that host.
157
+ var held = {};
158
+ try {
159
+ for (var b = 0; b < blocked.length; b++) {
160
+ var rec = blocked[b];
161
+ if (!rec || !rec.host) { continue; }
162
+ // A host held more than once keeps its WORST outcome: one dead tag on a
163
+ // host is the fact worth surfacing.
164
+ if (held[rec.host] && held[rec.host].revived === false) { continue; }
165
+ held[rec.host] = rec;
166
+ }
167
+ } catch (e2) { /* noop */ }
168
+
169
+ var out = [];
170
+ for (var i = 0; i < requests.length; i++) {
171
+ var q = requests[i];
172
+ if (q.when !== 'before') { out.push(q); continue; }
173
+ /* A `necessary` host is never held — allowed('necessary') is always true —
174
+ so it appears in no `blocked` record, and every note below would be a
175
+ lie about it: «раньше строки баннера» (we never wanted to hold it) or
176
+ «Consent Mode: без cookie» (it is not a Consent Mode decision). §4 files
177
+ necessary under `ok`, and §3's four notes have no slot for it, so it
178
+ gets what an after-consent row gets: nothing. Sending the owner off to
179
+ chase __cf_bm is exactly the noise §3 exists to remove. */
180
+ if (q.category === 'necessary') { out.push(q); continue; }
181
+ var h = held[q.host];
182
+ var why;
183
+ if (h && h.revived === false) { why = 'dead'; }
184
+ else if (h) { why = 'held'; }
185
+ else if (gcmDenied) { why = 'gcm'; }
186
+ else { why = 'early'; }
187
+ var copy = {};
188
+ for (var k in q) {
189
+ if (Object.prototype.hasOwnProperty.call(q, k)) { copy[k] = q[k]; }
190
+ }
191
+ copy.why = why;
192
+ out.push(copy);
193
+ }
194
+ return out;
195
+ }
196
+
111
197
  function buildReport(input) {
112
198
  var d = input || {};
113
199
  var st = d.state || {};
@@ -148,7 +234,11 @@
148
234
  revived: b.revived !== false
149
235
  };
150
236
  }),
151
- requests: buildRequests(d.entries, d.consentAtMs, d.classify),
237
+ requests: explainRequests(
238
+ buildRequests(d.entries, d.consentAtMs, d.classify),
239
+ d.blocked || [],
240
+ d.consentMode || []
241
+ ),
152
242
  consentMode: (d.consentMode || []).slice(),
153
243
  // Names only — a consent debug panel must never leak cookie contents.
154
244
  cookieNames: (d.cookieNames || []).slice(),
@@ -161,6 +251,8 @@
161
251
  'такие теги размечают вручную.';
162
252
  var NOTE_EN = 'Requests that left before ConsentKit loaded (a plain <script src> ' +
163
253
  'written into the HTML) show up here but cannot be blocked — mark such tags up manually.';
254
+ var NOTE_RO = 'Cererile plecate înainte de încărcarea ConsentKit (un <script src> ' +
255
+ 'obișnuit scris în HTML) apar aici, dar nu pot fi blocate — astfel de etichete se marchează manual.';
164
256
 
165
257
  // ---------------------------------------------------------------------------
166
258
  // Panel language (pure; the JSON report stays language-neutral either way)
@@ -191,6 +283,13 @@
191
283
  markup: ' (разметка)',
192
284
  strict: 'strict',
193
285
  notRevived: ' — не ожил после согласия',
286
+ // SPEC V1.12 §3 — пометка словами на каждой строке «до согласия»:
287
+ // что именно случилось и можно ли было это остановить.
288
+ whyEarly: 'раньше строки баннера — задержать не можем',
289
+ whyGcm: 'Consent Mode: без cookie',
290
+ whyHeld: 'задержан баннером',
291
+ whyDead: 'не ожил после согласия — проверьте, что тег помечен type="text/plain"',
292
+ cabinet: 'Что с этим делать — в кабинете',
194
293
  secRequests: 'Запросы к трекерам',
195
294
  noRequests: 'запросов к известным трекерам не было',
196
295
  after: 'после согласия',
@@ -252,6 +351,11 @@
252
351
  markup: ' (markup)',
253
352
  strict: 'strict',
254
353
  notRevived: ' — did not come back after consent',
354
+ whyEarly: 'loaded before the banner line — we cannot hold it',
355
+ whyGcm: 'Consent Mode: no cookies',
356
+ whyHeld: 'held back by the banner',
357
+ whyDead: 'did not come back after consent — check the tag is marked type="text/plain"',
358
+ cabinet: 'What to do about it — in your account',
255
359
  secRequests: 'Tracker requests',
256
360
  noRequests: 'no requests to known trackers',
257
361
  after: 'after consent',
@@ -291,6 +395,77 @@
291
395
  copied: 'Copied',
292
396
  copyFailed: 'Failed',
293
397
  footer: 'This panel is visible in this browser only. To turn it off, add ?ck_debug=0 to the URL.'
398
+ },
399
+ /* SPEC V1.12 §3 asks for the «до согласия» notes in ru/ro/en, so ro joins
400
+ the panel here. It was deliberately absent before — the comment above
401
+ still holds for every OTHER language: this is an internal diagnostic
402
+ surface and a half-translated one is worse than an English one. ro is
403
+ whole. */
404
+ ro: {
405
+ regionLabel: 'ConsentKit — mod de depanare',
406
+ collapse: 'Restrânge',
407
+ expand: 'Extinde',
408
+ closeLabel: 'Închide și oprește modul de depanare',
409
+ secClient: 'Client',
410
+ version: 'versiune',
411
+ source: 'sursă',
412
+ srcSaas: 'SaaS',
413
+ srcInline: 'inline',
414
+ secConsent: 'Consimțământ',
415
+ decidedAt: 'decizie',
416
+ method: 'mod',
417
+ ttl: 'durata cookie-ului',
418
+ days: ' zile',
419
+ status: { none: 'fără decizie', accepted: 'acceptat', rejected: 'respins', partial: 'parțial' },
420
+ secBlocked: 'Blocat până la consimțământ',
421
+ noBlocked: 'nimic interceptat',
422
+ markup: ' (marcaj)',
423
+ strict: 'strict',
424
+ notRevived: ' — nu a repornit după consimțământ',
425
+ whyEarly: 'încărcat înaintea liniei bannerului — nu îl putem opri',
426
+ whyGcm: 'Consent Mode: fără cookie-uri',
427
+ whyHeld: 'reținut de banner',
428
+ whyDead: 'nu a repornit după consimțământ — verificați că eticheta are type="text/plain"',
429
+ cabinet: 'Ce este de făcut — în contul dumneavoastră',
430
+ secRequests: 'Cereri către urmăritori',
431
+ noRequests: 'nu au fost cereri către urmăritori cunoscuți',
432
+ after: 'după consimțământ',
433
+ before: 'înainte de consimțământ',
434
+ ms: ' ms',
435
+ note: NOTE_RO,
436
+ secConsentMode: 'Consent Mode / dataLayer',
437
+ noEvents: 'niciun eveniment',
438
+ secTheme: 'Aspect',
439
+ themeMode: 'temă',
440
+ themeModeLight: 'deschisă',
441
+ themeModeDark: 'închisă',
442
+ themeFont: 'Font',
443
+ themeFontInherit: 'moștenit',
444
+ themeFontSystem: 'de sistem',
445
+ themeFontPage: 'din pagină',
446
+ themeFontTry: 'încercarea',
447
+ themeRadius: 'colțuri',
448
+ themeCard: 'card',
449
+ themeBtn: 'butoane',
450
+ themeLink: 'Linkuri',
451
+ btnAccept: 'Acceptă tot',
452
+ btnReject: 'Respinge tot',
453
+ btnSettings: 'Personalizează',
454
+ btnFilled: 'plin',
455
+ btnOutline: 'contur',
456
+ btnText: 'text',
457
+ btnBorder: 'contur',
458
+ btnOn: 'pe',
459
+ btnAdjusted: 'corectat automat',
460
+ btnOk: 'AA',
461
+ btnFail: 'sub AA',
462
+ secActions: 'Acțiuni',
463
+ reset: 'Resetează consimțământul',
464
+ showPrefs: 'Arată setările',
465
+ copy: 'Copiază raportul',
466
+ copied: 'Copiat',
467
+ copyFailed: 'Nu a mers',
468
+ footer: 'Acest panou este vizibil doar în acest browser. Pentru a-l opri, adăugați ?ck_debug=0 la adresă.'
294
469
  }
295
470
  };
296
471
 
@@ -299,7 +474,11 @@
299
474
  function pickLang(cfgLang, navLang) {
300
475
  var raw = String(cfgLang || '').toLowerCase();
301
476
  if (!raw || raw === 'auto') { raw = String(navLang || '').toLowerCase(); }
302
- return raw.slice(0, 2) === 'ru' ? 'ru' : 'en';
477
+ var two = raw.slice(0, 2);
478
+ if (two === 'ru') { return 'ru'; }
479
+ // 'mo' is the legacy Moldovan tag some browsers still send for Romanian.
480
+ if (two === 'ro' || two === 'mo') { return 'ro'; }
481
+ return 'en';
303
482
  }
304
483
 
305
484
  // Testable surface. Published before the activation check so the inactive
@@ -311,6 +490,17 @@
311
490
  stripUrl: stripUrl,
312
491
  pickLang: pickLang,
313
492
  strings: STRINGS,
493
+ // SPEC V1.12 §3 — the per-row «почему» rule, pure and testable: given the
494
+ // requests, what the engine intercepted and the Consent Mode signals, which
495
+ // of the four notes does each «до согласия» row get?
496
+ explainRequests: explainRequests,
497
+ whyKeys: WHY_KEY,
498
+ // SPEC V1.12 §3 — the key render() compares to decide whether the DOM
499
+ // needs rebuilding at all. Exported so the rule is testable: two reports
500
+ // that differ only in `generatedAt` must produce the SAME key (that field
501
+ // is a fresh timestamp on every tick and would otherwise defeat the check
502
+ // entirely), and any real change must produce a different one.
503
+ reportKey: reportKey,
314
504
  active: false
315
505
  };
316
506
  try {
@@ -505,6 +695,21 @@
505
695
  return out;
506
696
  }
507
697
 
698
+ /* Where the owner's account lives, when this build knows.
699
+
700
+ `texts.cabinetUrl` is server-owned and injected by the SaaS config, exactly
701
+ like `declarationUrl` in ck-ui.js — the client only reads it and never
702
+ invents one. http(s) only, because it becomes an href. An inline build has
703
+ none and gets the plain sentence instead. */
704
+ function cabinetUrl() {
705
+ try {
706
+ var t = CK && CK.config && CK.config.texts;
707
+ var u = t && t.cabinetUrl;
708
+ if (typeof u === 'string' && /^https?:\/\//i.test(u.trim())) { return u.trim(); }
709
+ } catch (e) { /* noop */ }
710
+ return null;
711
+ }
712
+
508
713
  function reportInput() {
509
714
  var s = saasInfo();
510
715
  var cfg = (CK && CK.config) || {};
@@ -560,6 +765,11 @@
560
765
  '.t.off{background:#3a1a1d;color:#ff9aa2}',
561
766
  '.mut{color:#8d96ab}',
562
767
  '.note{margin:6px 0 0;color:#8d96ab;font-size:11px;line-height:1.4}',
768
+ /* SPEC V1.12 §3 — the plain-words note under a «до согласия» row. Indented
769
+ to the width of the two tags above it, so it reads as belonging to that
770
+ row rather than as a new entry. */
771
+ '.why{margin:2px 0 0;color:#c2b078;font-size:11px;line-height:1.4}',
772
+ 'a.note{display:inline-block;color:#7aa2ff}',
563
773
  '.row{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}',
564
774
  '@media (prefers-reduced-motion:no-preference){button{transition:background-color .12s ease}}'
565
775
  ].join('');
@@ -638,10 +848,49 @@
638
848
  return T;
639
849
  }
640
850
 
641
- function render() {
851
+ /* SPEC V1.12 §3 — the two render() fixes.
852
+
853
+ THE JUMP: render() empties `body` and rebuilds it, which resets
854
+ `body.scrollTop` to 0. On a page that keeps producing requests the 3s
855
+ scheduler then yanks the panel back to the top every three seconds, and
856
+ reading anything past the fold is impossible. Saved before the clear,
857
+ restored after the rebuild.
858
+
859
+ THE REDRAW: with the report unchanged, rebuilding the DOM is pure churn —
860
+ it kills text selection, closes nothing the user opened, and is what makes
861
+ the jump above happen at all. `renderKey` is the serialised report with
862
+ `generatedAt` dropped: that field is a fresh `new Date().toISOString()` on
863
+ every tick, so comparing the report as-is would never match and would
864
+ suppress nothing. `null` until the first render, so the first one always
865
+ happens. */
866
+ var renderKey = null;
867
+
868
+ function reportKey(r) {
869
+ try {
870
+ var copy = {};
871
+ for (var k in r) {
872
+ if (Object.prototype.hasOwnProperty.call(r, k) && k !== 'generatedAt') { copy[k] = r[k]; }
873
+ }
874
+ return JSON.stringify(copy);
875
+ } catch (e) { return null; }
876
+ }
877
+
878
+ function render(force) {
642
879
  if (!body) { return; }
880
+ var langBefore = T;
643
881
  refreshLang();
644
882
  var r = buildReport(reportInput());
883
+
884
+ var key = reportKey(r);
885
+ // A language switch changes every string without changing the report, so it
886
+ // is its own reason to redraw.
887
+ if (!force && key !== null && key === renderKey && langBefore === T) { return; }
888
+ renderKey = key;
889
+
890
+ // Saved BEFORE the clear: an emptied body has no scroll height and reports 0.
891
+ var scrollTop = 0;
892
+ try { scrollTop = body.scrollTop || 0; } catch (e) { scrollTop = 0; }
893
+
645
894
  body.textContent = '';
646
895
 
647
896
  // 1. Client
@@ -707,11 +956,28 @@
707
956
  li.appendChild(tag(q.category));
708
957
  li.appendChild(doc.createTextNode(q.host + q.path + ' · ' + q.at + T.ms +
709
958
  (q.count > 1 ? ' ×' + q.count : '')));
959
+ // SPEC V1.12 §3 — the plain-words note, on its own line so it reads as a
960
+ // sentence rather than as another tag. Only «до согласия» rows have one.
961
+ if (q.why && WHY_KEY[q.why]) {
962
+ li.appendChild(el('div', { class: 'why' }, T[WHY_KEY[q.why]]));
963
+ }
710
964
  u4.appendChild(li);
711
965
  });
712
966
  s4.appendChild(u4);
713
967
  }
714
968
  s4.appendChild(el('p', { class: 'note' }, T.note));
969
+ /* «Что с этим делать — в кабинете» (§3). A LINK when this build knows the
970
+ cabinet's address and plain text otherwise — §3 says no URL is needed if
971
+ none is known, and a dead link would be worse than a sentence. The
972
+ address is server-owned, exactly like `declarationUrl`: the SaaS config
973
+ injects it and the client only reads it, http(s) only. */
974
+ var cab = cabinetUrl();
975
+ if (cab) {
976
+ var a = el('a', { href: cab, target: '_blank', rel: 'noopener noreferrer', class: 'note' }, T.cabinet);
977
+ s4.appendChild(a);
978
+ } else {
979
+ s4.appendChild(el('p', { class: 'note' }, T.cabinet));
980
+ }
715
981
  body.appendChild(s4);
716
982
 
717
983
  // 5. Consent Mode
@@ -821,6 +1087,10 @@
821
1087
  s6.appendChild(row);
822
1088
  s6.appendChild(el('p', { class: 'note' }, T.footer));
823
1089
  body.appendChild(s6);
1090
+
1091
+ // Put the reader back where they were (§3). Wrapped: a body detached
1092
+ // between the save and here has no scrollTop to write.
1093
+ try { if (scrollTop) { body.scrollTop = scrollTop; } } catch (e) { /* noop */ }
824
1094
  }
825
1095
 
826
1096
  function schedule() {
package/src/ck-locales.js CHANGED
@@ -572,6 +572,18 @@
572
572
  colPurpose: 'Pentru ce',
573
573
  colExpiry: 'Expiră',
574
574
  floating: 'Setări cookie-uri',
575
+ // SPEC V1.10 §2 — заглушка встраивания. Only ro carries these here: en and
576
+ // ru are builtin in ck-ui.js, and every other locale falls back to en.
577
+ phText: 'Aici este conținut de la {host}. Se va încărca după acordul pentru «{cat}».',
578
+ phAllow: 'Permite și arată',
579
+ phSettings: 'Setări cookie-uri',
580
+ phLabel: 'Conținut blocat',
581
+ // SPEC V1.12 §3 — servicii în grupul categoriei. Româna are trei forme:
582
+ // 1 serviciu, 2 servicii, 20 de servicii (peste 19 cere «de»).
583
+ svcCount: ['{n} serviciu', '{n} servicii', '{n} de servicii'],
584
+ ckCount: ['{n} cookie', '{n} cookie-uri', '{n} de cookie-uri'],
585
+ svcPolicy: 'Politica',
586
+ svcCookies: 'Ce cookie-uri pune',
575
587
  cat: {
576
588
  necessary: { title: 'Necesare', desc: 'Sunt necesare pentru funcționarea site-ului: autentificare, securitate, reținerea alegerii dumneavoastră. Nu pot fi dezactivate.' },
577
589
  functional: { title: 'Funcționale', desc: 'Rețin alegerile dumneavoastră: limba, coșul, chatul.' },
package/src/ck-saas.js CHANGED
@@ -298,9 +298,42 @@
298
298
  if (lang) { body.lang = lang; }
299
299
  var layout = resolvedLayout();
300
300
  if (layout) { body.layout = layout; }
301
+
302
+ /* SPEC V1.12 §3 — the optional `services` field: the ids the visitor
303
+ switched off, «только при method: 'custom'».
304
+
305
+ Three guards, all of them load-bearing against a 400 from a closed
306
+ schema: only on a 'custom' decision (an accept_all or reject_all has no
307
+ per-service refusals to report and a withdraw is not a choice about
308
+ services at all), only when the list is non-empty (an empty array is a
309
+ field the server did not need to receive), and capped at 50 ids of at
310
+ most 64 characters each — the same bounds §2 puts on the config. */
311
+ if (body.method === 'custom') {
312
+ var denied = deniedServices();
313
+ if (denied.length) { body.services = denied; }
314
+ }
301
315
  return body;
302
316
  }
303
317
 
318
+ // Read from the core, which normalised and bounded the ids already; re-checked
319
+ // here anyway, because this is the last place before the wire.
320
+ function deniedServices() {
321
+ var out = [];
322
+ try {
323
+ var ck = global.ConsentKit;
324
+ if (!ck || typeof ck._deniedServices !== 'function') { return out; }
325
+ var list = ck._deniedServices();
326
+ if (!list || typeof list.length !== 'number') { return out; }
327
+ for (var i = 0; i < list.length && out.length < 50; i++) {
328
+ var id = list[i];
329
+ if (typeof id === 'string' && id && id.length <= 64 && out.indexOf(id) === -1) {
330
+ out.push(id);
331
+ }
332
+ }
333
+ } catch (e) { /* noop */ }
334
+ return out;
335
+ }
336
+
304
337
  function drop(payload) {
305
338
  var i = pending.indexOf(payload);
306
339
  if (i > -1) { pending.splice(i, 1); }