@ecomconsult/consentkit 0.5.13 → 0.5.15
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 +203 -9
- package/npm/index.d.ts +35 -1
- package/npm/internal-stub.mjs +1 -1
- package/package.json +1 -1
- package/src/ck-core.js +24 -1
- package/src/ck-debug.js +74 -6
- package/src/ck-locales.js +36 -1
- package/src/ck-saas.js +154 -6
- package/src/ck-ui.js +554 -6
package/src/ck-ui.js
CHANGED
|
@@ -5,6 +5,18 @@
|
|
|
5
5
|
(function () {
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
+
/* SPEC §1.9 — the second copy of ck.js on the page draws nothing.
|
|
9
|
+
Two snippets each load the whole bundle, so this file runs twice. Every
|
|
10
|
+
copy is its own IIFE with its own `mounted` flag, so a second copy would
|
|
11
|
+
register a second set of ck:* listeners and mount a second banner into the
|
|
12
|
+
same #ck-root — the first copy's, which it cannot see. ck-core.js stands
|
|
13
|
+
down for the same reason; this is the UI half of it.
|
|
14
|
+
SSR-safe: with no window we behave exactly as before and fall through. The
|
|
15
|
+
flag is claimed further down, after the `typeof document` guard, so a copy
|
|
16
|
+
that only ever published the pure `_contrast` helpers into a DOM-less
|
|
17
|
+
context does not lock out a later copy that could actually render. */
|
|
18
|
+
if (typeof window !== 'undefined' && window.__ckUiLoaded) { return; }
|
|
19
|
+
|
|
8
20
|
var OPT_IN = ['functional', 'analytics', 'marketing'];
|
|
9
21
|
var ALL_CATS = ['necessary'].concat(OPT_IN);
|
|
10
22
|
|
|
@@ -21,6 +33,15 @@
|
|
|
21
33
|
bannerLabel: 'Cookie consent',
|
|
22
34
|
panelTitle: 'Cookie settings',
|
|
23
35
|
panelIntro: 'Choose what to allow. By default only the necessary ones are on.',
|
|
36
|
+
/* SPEC V1.16 §1.2 — the «Additional information» card at the foot of the
|
|
37
|
+
panel: who the operator is, where to write, how long an answer takes,
|
|
38
|
+
where to complain. The HEADING has a real translation in every locale;
|
|
39
|
+
the BODY is deliberately empty in every dictionary, because the block
|
|
40
|
+
renders only when the site actually supplies text. A default sentence
|
|
41
|
+
here would put an empty operator card on every site that never
|
|
42
|
+
configured one. */
|
|
43
|
+
extraTitle: 'Additional information',
|
|
44
|
+
extraText: '',
|
|
24
45
|
save: 'Save choices',
|
|
25
46
|
close: 'Close',
|
|
26
47
|
alwaysOn: 'always on',
|
|
@@ -89,6 +110,10 @@
|
|
|
89
110
|
bannerLabel: 'Согласие на cookie',
|
|
90
111
|
panelTitle: 'Настройки cookie',
|
|
91
112
|
panelIntro: 'Выберите, что разрешить. По умолчанию включено только необходимое.',
|
|
113
|
+
// SPEC V1.16 §1.2. Заголовок переведён везде, тело пустое везде — см.
|
|
114
|
+
// комментарий у en выше.
|
|
115
|
+
extraTitle: 'Дополнительно',
|
|
116
|
+
extraText: '',
|
|
92
117
|
save: 'Сохранить выбор',
|
|
93
118
|
close: 'Закрыть',
|
|
94
119
|
alwaysOn: 'всегда активны',
|
|
@@ -157,7 +182,16 @@
|
|
|
157
182
|
// group's own one. Both are plain strings, so they belong here: a key left
|
|
158
183
|
// out of this list is `undefined` for all 32 external locales and renders
|
|
159
184
|
// the literal word "undefined" on the card.
|
|
160
|
-
'svcDetails', 'svcListLabel'
|
|
185
|
+
'svcDetails', 'svcListLabel',
|
|
186
|
+
/* SPEC V1.16 §1.2 — the «Дополнительно» card. Both belong here for the
|
|
187
|
+
usual reason (a DICT key missing from this list is `undefined` for all 32
|
|
188
|
+
external locales), but they behave differently on purpose:
|
|
189
|
+
|
|
190
|
+
extraTitle — translated everywhere, so every locale has a heading.
|
|
191
|
+
extraText — '' in every dictionary. The merge line below keeps '' when
|
|
192
|
+
the locale supplies nothing, and the panel treats '' as
|
|
193
|
+
«no block». Only config.texts.<lang>.extraText fills it. */
|
|
194
|
+
'extraTitle', 'extraText'
|
|
161
195
|
];
|
|
162
196
|
|
|
163
197
|
// Plural-form keys, filled separately from STR_KEYS (see above).
|
|
@@ -248,13 +282,74 @@
|
|
|
248
282
|
return list[idx].replace('{n}', String(n));
|
|
249
283
|
}
|
|
250
284
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
285
|
+
/* SPEC V1.16 §1.1 — per-language dictionary overrides from the CONFIG.
|
|
286
|
+
|
|
287
|
+
`config.texts` is a mixed bag: it has carried `policyUrl`, `detailsAction`,
|
|
288
|
+
`declarationUrl` and `cabinetUrl` since 0.5.0, and 0.5.15 adds `links`. So a
|
|
289
|
+
key is a language dictionary only when it LOOKS like a language tag — that
|
|
290
|
+
regex is the whole separation, and it is why no future scalar setting under
|
|
291
|
+
`texts` can ever be mistaken for one (none of them is two letters).
|
|
292
|
+
|
|
293
|
+
Own properties only, and the value must be a plain object: `texts` is
|
|
294
|
+
author-supplied JSON that may have been merged over a prototype, and
|
|
295
|
+
reading an inherited key would hand the merge below a function. */
|
|
296
|
+
var LANG_KEY_RE = /^[a-z]{2}(-[a-z]{2})?$/;
|
|
297
|
+
|
|
298
|
+
function configTexts(cfg, code) {
|
|
299
|
+
var texts = (cfg && cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
|
|
300
|
+
? cfg.texts : null;
|
|
301
|
+
if (!texts) return null;
|
|
302
|
+
var k = String(code || '').toLowerCase();
|
|
303
|
+
if (!LANG_KEY_RE.test(k)) return null;
|
|
304
|
+
if (!Object.prototype.hasOwnProperty.call(texts, k)) return null;
|
|
305
|
+
var v = texts[k];
|
|
306
|
+
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* The keys an override may reach. A WHITELIST, not «whatever the object
|
|
310
|
+
carries»: an operator writing their own banner copy has no business
|
|
311
|
+
rewriting «Принять всё» (the button labels are what a visitor recognises
|
|
312
|
+
across sites) or the plural tables (an array here would break plural()).
|
|
313
|
+
`cat.<name>.title/desc` is handled separately below, one level deeper. */
|
|
314
|
+
var OVERRIDE_KEYS = [
|
|
315
|
+
'bannerTitle', 'bannerText', 'panelTitle', 'panelIntro', 'extraTitle', 'extraText'
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
// A non-empty string wins; empty or missing falls through to the layer below.
|
|
319
|
+
// That is what makes an empty field in the cabinet mean «take the dictionary»
|
|
320
|
+
// rather than «render nothing».
|
|
321
|
+
function pickOverride(src, key) {
|
|
322
|
+
if (!src) return null;
|
|
323
|
+
if (!Object.prototype.hasOwnProperty.call(src, key)) return null;
|
|
324
|
+
var v = src[key];
|
|
325
|
+
return (typeof v === 'string' && v) ? v : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* Deep two-level fill from en: a partial locale must never yield undefined,
|
|
329
|
+
which would render the literal string "undefined".
|
|
330
|
+
|
|
331
|
+
Three layers as of 0.5.15: builtin DICT <- window.__ckLocales (both already
|
|
332
|
+
folded into `table`) <- config.texts[lang]. `cfg` is OPTIONAL on purpose —
|
|
333
|
+
placeholderText() and the cabinet's own calls ask for the plain dictionary,
|
|
334
|
+
and an absent third argument must build exactly what 0.5.14 built. */
|
|
335
|
+
function buildStrings(lang, table, cfg) {
|
|
254
336
|
var src = table[lang] || {};
|
|
255
337
|
var base = DICT.en;
|
|
256
338
|
var out = {};
|
|
257
339
|
var i, c;
|
|
340
|
+
|
|
341
|
+
/* Exact code first, then the two-letter base of the RESOLVED language:
|
|
342
|
+
a banner that resolved to `pt-br` takes `texts['pt-br']` over
|
|
343
|
+
`texts['pt']`, and a site that only wrote `texts.pt` still reaches it.
|
|
344
|
+
Same shape as resolveLang(), so the override follows the banner. */
|
|
345
|
+
var code = String(lang || '').toLowerCase();
|
|
346
|
+
var ov = configTexts(cfg, code);
|
|
347
|
+
var ovBase = (code.length > 2) ? configTexts(cfg, code.slice(0, 2)) : null;
|
|
348
|
+
|
|
349
|
+
function override(key) {
|
|
350
|
+
return pickOverride(ov, key) || pickOverride(ovBase, key);
|
|
351
|
+
}
|
|
352
|
+
|
|
258
353
|
for (i = 0; i < STR_KEYS.length; i++) {
|
|
259
354
|
var k = STR_KEYS[i];
|
|
260
355
|
out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
|
|
@@ -278,9 +373,309 @@
|
|
|
278
373
|
desc: (typeof e.desc === 'string' && e.desc) ? e.desc : base.cat[c].desc
|
|
279
374
|
};
|
|
280
375
|
}
|
|
376
|
+
|
|
377
|
+
/* SPEC V1.16 §1.1 — the config layer, applied LAST so it wins over both the
|
|
378
|
+
builtin dictionary and __ckLocales. Only the whitelisted keys, and only
|
|
379
|
+
non-empty strings: an override that is absent, empty or not a string
|
|
380
|
+
leaves whatever the two lower layers produced.
|
|
381
|
+
|
|
382
|
+
Nothing here is ever treated as HTML. These strings go through el()'s
|
|
383
|
+
textContent and (for extraText) renderRich(), which builds nodes with
|
|
384
|
+
createElement/createTextNode — an author who writes «<b>» sees the
|
|
385
|
+
characters «<b>», which is the contract the server's validator mirrors. */
|
|
386
|
+
if (ov || ovBase) {
|
|
387
|
+
for (i = 0; i < OVERRIDE_KEYS.length; i++) {
|
|
388
|
+
var ok = OVERRIDE_KEYS[i];
|
|
389
|
+
var hit = override(ok);
|
|
390
|
+
if (hit) out[ok] = hit;
|
|
391
|
+
}
|
|
392
|
+
// cat.<name>.title / cat.<name>.desc, one level deeper. Read from a
|
|
393
|
+
// `cat` object on the override, with the same exact-then-base chain.
|
|
394
|
+
var ovCat = (ov && ov.cat && typeof ov.cat === 'object') ? ov.cat : null;
|
|
395
|
+
var ovBaseCat = (ovBase && ovBase.cat && typeof ovBase.cat === 'object') ? ovBase.cat : null;
|
|
396
|
+
for (i = 0; i < ALL_CATS.length; i++) {
|
|
397
|
+
c = ALL_CATS[i];
|
|
398
|
+
var oe = (ovCat && ovCat[c] && typeof ovCat[c] === 'object') ? ovCat[c] : null;
|
|
399
|
+
var obe = (ovBaseCat && ovBaseCat[c] && typeof ovBaseCat[c] === 'object') ? ovBaseCat[c] : null;
|
|
400
|
+
var t = pickOverride(oe, 'title') || pickOverride(obe, 'title');
|
|
401
|
+
var d = pickOverride(oe, 'desc') || pickOverride(obe, 'desc');
|
|
402
|
+
if (t) out.cat[c].title = t;
|
|
403
|
+
if (d) out.cat[c].desc = d;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* ------------------------------------------ rich text (SPEC V1.16 §1.2) */
|
|
410
|
+
|
|
411
|
+
/* The tiny markup subset the «Дополнительно» block accepts. It exists because
|
|
412
|
+
an operator block is genuinely structured text — two or three paragraphs, a
|
|
413
|
+
bold e-mail address, a link to the supervisory authority — and because the
|
|
414
|
+
alternative (accepting HTML) would mean sanitising author HTML in the
|
|
415
|
+
browser, which is a class of bug we are not going to own.
|
|
416
|
+
|
|
417
|
+
THE RULES, in the order they are applied. The server's validator mirrors
|
|
418
|
+
this exactly, so the order is part of the contract, not an implementation
|
|
419
|
+
detail:
|
|
420
|
+
|
|
421
|
+
1. A blank line (two newlines, any surrounding spaces) splits paragraphs.
|
|
422
|
+
Each paragraph becomes one <p>.
|
|
423
|
+
2. Inside a paragraph, a single newline becomes <br>.
|
|
424
|
+
3. LINKS ARE TOKENISED FIRST, before anything else looks at the text:
|
|
425
|
+
[label](URL) with URL http(s):// or mailto:
|
|
426
|
+
a bare https?:// URL
|
|
427
|
+
a bare e-mail address
|
|
428
|
+
Link-first is not a preference — it is what stops the bare-URL rule
|
|
429
|
+
from eating the address inside «[Центр](https://datepersonale.md)»
|
|
430
|
+
and stops the bare-e-mail rule from firing inside a mailto: label.
|
|
431
|
+
4. `**bold**` is applied only to the TEXT RUNS BETWEEN links. So bold does
|
|
432
|
+
not nest inside a link label and a link does not appear inside bold —
|
|
433
|
+
one pass, no nesting, nothing recursive to get wrong.
|
|
434
|
+
5. Any other URL scheme in [label](…) — javascript:, data:, file: — is
|
|
435
|
+
NOT a link. The WHOLE literal «[label](javascript:…)» is rendered as
|
|
436
|
+
plain text: visibly wrong to whoever wrote it, rather than silently
|
|
437
|
+
dropping the address and leaving a label that looks like a link.
|
|
438
|
+
6. There is no HTML. «<b>» is five characters of text, always.
|
|
439
|
+
|
|
440
|
+
Returns an ARRAY of nodes, so the caller decides where they go. Pure apart
|
|
441
|
+
from document.createElement, which is what makes «no innerHTML» mechanical
|
|
442
|
+
rather than a promise. */
|
|
443
|
+
|
|
444
|
+
// [label](url) — the label may not contain brackets, the url may not contain
|
|
445
|
+
// whitespace or a closing paren. Deliberately strict: an address that needs
|
|
446
|
+
// an escaped paren is an address that belongs in a plain bare-URL run.
|
|
447
|
+
var MD_LINK_RE = /\[([^\]\[]+)\]\(([^()\s]+)\)/;
|
|
448
|
+
var BARE_URL_RE = /https?:\/\/[^\s<>()\[\]"']+[^\s<>()\[\]"'.,;:!?]/;
|
|
449
|
+
// Simple on purpose: an address that this misses renders as plain text, which
|
|
450
|
+
// is a worse link, not a security hole. Anything clever here is a false
|
|
451
|
+
// positive waiting to turn a sentence into a mailto:.
|
|
452
|
+
var BARE_MAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/;
|
|
453
|
+
|
|
454
|
+
// http(s) for an outbound link, mailto: for an address. Everything else is
|
|
455
|
+
// refused — a javascript: or data: URL in a control the visitor is invited to
|
|
456
|
+
// click is the XSS vector resolveDetails() already refuses for policyUrl.
|
|
457
|
+
function richHref(url) {
|
|
458
|
+
var u = str(url);
|
|
459
|
+
if (!u) return null;
|
|
460
|
+
if (/^https?:\/\//i.test(u)) return u;
|
|
461
|
+
if (/^mailto:[^\s]+@[^\s]+$/i.test(u)) return u;
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function richLink(label, href) {
|
|
466
|
+
var a = el('a', 'ck-rich__link', label);
|
|
467
|
+
a.href = href;
|
|
468
|
+
a.target = '_blank';
|
|
469
|
+
a.rel = 'noopener';
|
|
470
|
+
return a;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* `**bold**` over a plain run. Non-greedy and never across a newline, so an
|
|
474
|
+
unclosed «**» is two literal asterisks rather than a bold tail that
|
|
475
|
+
swallows the rest of the paragraph. */
|
|
476
|
+
var BOLD_RE = /\*\*([^*\n]+)\*\*/;
|
|
477
|
+
|
|
478
|
+
/* PASS 3 — bare addresses inside one run of plain text. Runs LAST, on text
|
|
479
|
+
that no longer contains any [label](url), so it cannot reach inside one. */
|
|
480
|
+
function richBare(text, out) {
|
|
481
|
+
var rest = String(text);
|
|
482
|
+
while (rest) {
|
|
483
|
+
var url = BARE_URL_RE.exec(rest);
|
|
484
|
+
var mail = BARE_MAIL_RE.exec(rest);
|
|
485
|
+
// Whichever address starts first. A URL wins a tie: «https://a@b.md» is
|
|
486
|
+
// an address with a userinfo part, not an e-mail sitting after a scheme.
|
|
487
|
+
var m = null, isMail = false;
|
|
488
|
+
if (url) { m = url; }
|
|
489
|
+
if (mail && (!m || mail.index < m.index)) { m = mail; isMail = true; }
|
|
490
|
+
if (!m) { out.push(document.createTextNode(rest)); return; }
|
|
491
|
+
if (m.index > 0) out.push(document.createTextNode(rest.slice(0, m.index)));
|
|
492
|
+
out.push(richLink(m[0], isMail ? ('mailto:' + m[0]) : m[0]));
|
|
493
|
+
rest = rest.slice(m.index + m[0].length);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* PASS 2 — `**bold**` over one run of text, then PASS 3 inside each half.
|
|
498
|
+
|
|
499
|
+
Bold has to come BEFORE the bare-address rules, not after: «**mail@x.md**»
|
|
500
|
+
is one bold run containing an address, and a pass that linked the address
|
|
501
|
+
first would split the run in three and leave BOLD_RE looking at «**» and
|
|
502
|
+
«**» separately — the asterisks would survive on screen. That is the whole
|
|
503
|
+
reason these are three ordered passes rather than one left-to-right walk. */
|
|
504
|
+
function richBold(text, out) {
|
|
505
|
+
var rest = String(text);
|
|
506
|
+
var m;
|
|
507
|
+
while ((m = BOLD_RE.exec(rest))) {
|
|
508
|
+
if (m.index > 0) richBare(rest.slice(0, m.index), out);
|
|
509
|
+
var strong = el('strong', null);
|
|
510
|
+
var inner = [];
|
|
511
|
+
richBare(m[1], inner); // an address inside bold is still a link
|
|
512
|
+
for (var i = 0; i < inner.length; i++) strong.appendChild(inner[i]);
|
|
513
|
+
out.push(strong);
|
|
514
|
+
rest = rest.slice(m.index + m[0].length);
|
|
515
|
+
}
|
|
516
|
+
if (rest) richBare(rest, out);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* PASS 1 — one line of a paragraph -> nodes.
|
|
520
|
+
|
|
521
|
+
`[label](url)` is tokenised first and its pieces never reach passes 2 and 3:
|
|
522
|
+
that is what stops the bare-URL rule from eating the address inside the
|
|
523
|
+
parens, stops the bare-e-mail rule from firing inside a mailto: label, and
|
|
524
|
+
leaves a label's own «**» literal (rule 4 of the contract). The text
|
|
525
|
+
BETWEEN markdown links goes through richBold(), which then runs richBare()
|
|
526
|
+
inside and outside each bold span. */
|
|
527
|
+
function richLine(line, out) {
|
|
528
|
+
var rest = String(line);
|
|
529
|
+
var m;
|
|
530
|
+
while ((m = MD_LINK_RE.exec(rest))) {
|
|
531
|
+
if (m.index > 0) richBold(rest.slice(0, m.index), out);
|
|
532
|
+
var href = richHref(m[2]);
|
|
533
|
+
// Rule 5: a refused scheme is the whole literal, verbatim and unbolded,
|
|
534
|
+
// so «[x](javascript:alert(1))» reads as exactly what was written.
|
|
535
|
+
if (href) out.push(richLink(m[1], href));
|
|
536
|
+
else out.push(document.createTextNode(m[0]));
|
|
537
|
+
rest = rest.slice(m.index + m[0].length);
|
|
538
|
+
}
|
|
539
|
+
if (rest) richBold(rest, out);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* text -> array of <p> nodes. Empty (or non-string) input gives an empty
|
|
543
|
+
array, which is what lets the caller say «no text, no block» by asking for
|
|
544
|
+
the length rather than by re-testing the string. */
|
|
545
|
+
function renderRich(text) {
|
|
546
|
+
var s = (typeof text === 'string') ? text : '';
|
|
547
|
+
if (!s.trim()) return [];
|
|
548
|
+
// \r\n and \r normalised first: a value pasted from Windows or copied out of
|
|
549
|
+
// a textarea must split into the same paragraphs as one typed here.
|
|
550
|
+
var paras = s.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n+/);
|
|
551
|
+
var out = [];
|
|
552
|
+
for (var i = 0; i < paras.length; i++) {
|
|
553
|
+
var para = paras[i].replace(/^\n+|\n+$/g, '');
|
|
554
|
+
if (!para.trim()) continue;
|
|
555
|
+
var p = el('p', 'ck-rich__p');
|
|
556
|
+
var lines = para.split('\n');
|
|
557
|
+
for (var j = 0; j < lines.length; j++) {
|
|
558
|
+
if (j > 0) p.appendChild(el('br')); // rule 2: a single newline
|
|
559
|
+
var nodes = [];
|
|
560
|
+
richLine(lines[j], nodes);
|
|
561
|
+
for (var k = 0; k < nodes.length; k++) p.appendChild(nodes[k]);
|
|
562
|
+
}
|
|
563
|
+
out.push(p);
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* ------------------------------------------ banner links (SPEC V1.16 §1.3) */
|
|
569
|
+
|
|
570
|
+
/* `texts.links` — up to three of the operator's own addresses, rendered under
|
|
571
|
+
the banner buttons and at the foot of the settings panel. The obvious pair
|
|
572
|
+
is «Политика конфиденциальности» and «Политика cookie», which a Moldovan
|
|
573
|
+
site is required to publish and which have nowhere else to go on the card.
|
|
574
|
+
|
|
575
|
+
Deliberately NOT tangled with `detailsAction`: «Подробнее» keeps doing
|
|
576
|
+
exactly what it did in 0.5.14, because a site that gains a link row must
|
|
577
|
+
not silently lose the link it already had.
|
|
578
|
+
|
|
579
|
+
A row is skipped, never faked, when its address is not http(s) or when no
|
|
580
|
+
label resolves for the banner's language — a link with no words on it is
|
|
581
|
+
not a link. The cap is applied to the SURVIVORS: one malformed row must not
|
|
582
|
+
cost a good one its place. */
|
|
583
|
+
function linkLabel(labels, lang) {
|
|
584
|
+
if (!labels || typeof labels !== 'object' || Array.isArray(labels)) return null;
|
|
585
|
+
var code = (typeof lang === 'string') ? lang.toLowerCase() : '';
|
|
586
|
+
// Own properties only, exactly as branding's pickPoweredByText does it and
|
|
587
|
+
// for the same reason: `labels` is author-supplied JSON, and an inherited
|
|
588
|
+
// key would hand str() a function instead of a missing line.
|
|
589
|
+
function own(k) {
|
|
590
|
+
return (k && Object.prototype.hasOwnProperty.call(labels, k)) ? str(labels[k]) : null;
|
|
591
|
+
}
|
|
592
|
+
var hit = own(code);
|
|
593
|
+
if (!hit && code.length > 2) hit = own(code.slice(0, 2)); // ru-RU -> ru
|
|
594
|
+
return hit || own('en');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
var MAX_LINKS = 3;
|
|
598
|
+
|
|
599
|
+
function resolveLinks(cfg, lang) {
|
|
600
|
+
var texts = (cfg && cfg.texts && typeof cfg.texts === 'object') ? cfg.texts : null;
|
|
601
|
+
var list = texts && texts.links;
|
|
602
|
+
if (!list || !Array.isArray(list)) return [];
|
|
603
|
+
var out = [];
|
|
604
|
+
for (var i = 0; i < list.length && out.length < MAX_LINKS; i++) {
|
|
605
|
+
var row = list[i];
|
|
606
|
+
if (!row || typeof row !== 'object') continue;
|
|
607
|
+
var url = str(row.url);
|
|
608
|
+
if (!url || !/^https?:\/\//i.test(url)) continue;
|
|
609
|
+
var label = linkLabel(row.label, lang);
|
|
610
|
+
if (!label) continue;
|
|
611
|
+
out.push({ id: str(row.id) || ('link' + i), url: url, label: label });
|
|
612
|
+
}
|
|
281
613
|
return out;
|
|
282
614
|
}
|
|
283
615
|
|
|
616
|
+
/* SPEC V1.16 §1.3, last sentence — the one place where the link row DOES
|
|
617
|
+
touch «Подробнее»: when the address it would send the visitor to is
|
|
618
|
+
already one of the rows under the buttons, the banner shows the same
|
|
619
|
+
address twice. The second copy is not more information, it is noise, so
|
|
620
|
+
the in-text link is dropped and the banner renders as `detailsAction:
|
|
621
|
+
'hide'` for that render — the link row keeps the address, with a label the
|
|
622
|
+
operator wrote, which is the better of the two.
|
|
623
|
+
|
|
624
|
+
Only 'policy' and 'declaration' can duplicate anything: 'settings' opens
|
|
625
|
+
the panel and has no address to collide with, and 'hide' is already gone.
|
|
626
|
+
|
|
627
|
+
Comparison is deliberately narrow. Trimming and a trailing slash are
|
|
628
|
+
typography, not identity — `https://shop.md/cookies` and
|
|
629
|
+
`…/cookies/` are the same page — and so is the case of the HOST, which
|
|
630
|
+
DNS does not distinguish. The PATH is left alone: `/Cookies` and
|
|
631
|
+
`/cookies` are genuinely two addresses on a case-sensitive server, and
|
|
632
|
+
suppressing a real «Подробнее» link over that guess is the worse error. */
|
|
633
|
+
function normHref(v) {
|
|
634
|
+
var s = str(v);
|
|
635
|
+
if (!s) return null;
|
|
636
|
+
var m = /^(https?:\/\/[^\/?#]*)([\s\S]*)$/i.exec(s);
|
|
637
|
+
if (!m) return null;
|
|
638
|
+
return (m[1].toLowerCase() + m[2]).replace(/\/$/, '');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function detailsDuplicatesLink(details, links) {
|
|
642
|
+
if (!details) return false;
|
|
643
|
+
if (details.kind !== 'policy' && details.kind !== 'declaration') return false;
|
|
644
|
+
var href = normHref(details.href);
|
|
645
|
+
if (!href || !links || !Array.isArray(links)) return false;
|
|
646
|
+
for (var i = 0; i < links.length; i++) {
|
|
647
|
+
if (links[i] && normHref(links[i].url) === href) return true;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* Which of the three shapes the banner will actually draw, duplicate rule
|
|
653
|
+
applied — so buildBanner() and signature() cannot disagree about it.
|
|
654
|
+
`lang` is the code the banner resolved to; signature() passes the config's
|
|
655
|
+
own so both sides of a comparison are read in the same language. */
|
|
656
|
+
function detailsKind(cfg, lang) {
|
|
657
|
+
var det = resolveDetails(cfg);
|
|
658
|
+
var code = (typeof lang === 'string' && lang) ? lang :
|
|
659
|
+
resolveLang(cfg && cfg.language, localeTable());
|
|
660
|
+
return detailsDuplicatesLink(det, resolveLinks(cfg, code)) ? 'hide' : det.kind;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The row itself, or null when there is nothing to draw — so both call sites
|
|
664
|
+
// (banner and panel) are one `if` rather than two.
|
|
665
|
+
function buildLinksRow(cfg, cls) {
|
|
666
|
+
var links = resolveLinks(cfg, LANG);
|
|
667
|
+
if (!links.length) return null;
|
|
668
|
+
var row = el('div', 'ck-links' + (cls ? ' ' + cls : ''));
|
|
669
|
+
for (var i = 0; i < links.length; i++) {
|
|
670
|
+
var a = el('a', 'ck-links__a', links[i].label);
|
|
671
|
+
a.href = links[i].url;
|
|
672
|
+
a.target = '_blank';
|
|
673
|
+
a.rel = 'noopener';
|
|
674
|
+
row.appendChild(a);
|
|
675
|
+
}
|
|
676
|
+
return row;
|
|
677
|
+
}
|
|
678
|
+
|
|
284
679
|
/* ------------------------------------------- blocked-embed placeholder (§2) */
|
|
285
680
|
|
|
286
681
|
/* The one piece of the placeholder that is worth testing without a DOM: which
|
|
@@ -525,6 +920,40 @@
|
|
|
525
920
|
>= 4.5:1 rule as «Подробнее» — see the note on the `a{}` rule above. */
|
|
526
921
|
'.ck-svc__policy{display:inline-block;margin-top:4px;font-size:12.5px;color:var(--ck-link)}',
|
|
527
922
|
|
|
923
|
+
/* ---- «Дополнительно» card (SPEC V1.16 §1.2) ---- */
|
|
924
|
+
/* A card, not another category row: it carries no switch and answers a
|
|
925
|
+
different question — who the operator is and how to reach them. So it
|
|
926
|
+
gets the soft fill and the rounded border the cookie table wears, which
|
|
927
|
+
reads as «a panel of reference text» rather than «a fifth thing you can
|
|
928
|
+
turn off».
|
|
929
|
+
|
|
930
|
+
It is appended AFTER the categories, which means the last .ck-cat is no
|
|
931
|
+
longer `:last-child` and keeps its bottom rule — that is wanted here: the
|
|
932
|
+
rule is what separates the last category from this block. The rule below
|
|
933
|
+
restores the old look for the category above it only in the sense that
|
|
934
|
+
the separator now belongs to the boundary rather than to the group. */
|
|
935
|
+
'.ck-extra{margin:16px 0 4px;padding:14px 16px;background:var(--ck-soft);',
|
|
936
|
+
'border:1px solid var(--ck-line);border-radius:var(--ck-radius-card)}',
|
|
937
|
+
'.ck-extra__title{margin:0 0 6px;font-size:15px;font-weight:600}',
|
|
938
|
+
/* Same size and colour as .ck-cat__desc: this is secondary reference text,
|
|
939
|
+
and it is measured for AA against the card by the same rule. */
|
|
940
|
+
'.ck-extra__body p{margin:0 0 8px;font-size:13.5px;color:var(--ck-muted);line-height:1.5}',
|
|
941
|
+
'.ck-extra__body p:last-child{margin-bottom:0}',
|
|
942
|
+
/* --ck-link, not --ck-accent — accent-coloured TEXT answers to 4.5:1
|
|
943
|
+
against the card; see the note on the `a{}` rule above. */
|
|
944
|
+
'.ck-extra__body a{color:var(--ck-link)}',
|
|
945
|
+
|
|
946
|
+
/* ---- link row under the buttons (SPEC V1.16 §1.3) ---- */
|
|
947
|
+
/* One row, wrapping. `width:100%` matters in the bar layout, whose banner is
|
|
948
|
+
a flex row: without it the row would try to sit BESIDE the buttons on a
|
|
949
|
+
wide screen instead of under them. */
|
|
950
|
+
'.ck-links{display:flex;gap:14px;flex-wrap:wrap;width:100%;margin-top:12px;font-size:13px}',
|
|
951
|
+
'.ck-links__a{color:var(--ck-link)}',
|
|
952
|
+
/* The panel foot is a flex row of buttons that each take `flex:1 1 150px`;
|
|
953
|
+
a full-width row forced onto its own line keeps the links under the
|
|
954
|
+
buttons rather than wedged between two of them. */
|
|
955
|
+
'.ck-links--panel{flex-basis:100%;margin-top:2px}',
|
|
956
|
+
|
|
528
957
|
/* ---- switch ---- */
|
|
529
958
|
'.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
|
|
530
959
|
'border:1px solid var(--ck-line);background:var(--ck-soft);position:relative}',
|
|
@@ -573,6 +1002,9 @@
|
|
|
573
1002
|
'.ck-banner--bar .ck-banner__body{display:contents}',
|
|
574
1003
|
'.ck-banner--bar .ck-banner__body>*{order:1}',
|
|
575
1004
|
'.ck-banner--bar .ck-actions{order:2}',
|
|
1005
|
+
// SPEC V1.16 §1.3 — the link row is `order:3` so it stays under the buttons
|
|
1006
|
+
// in the bar's mobile column, where every child's position is explicit.
|
|
1007
|
+
'.ck-banner--bar .ck-links{order:3}',
|
|
576
1008
|
/* Reference: the box's button row becomes a column under 560px. */
|
|
577
1009
|
'.ck-banner--box .ck-actions{flex-direction:column}',
|
|
578
1010
|
'.ck-banner--box .ck-btn{flex:1 1 auto;width:100%}}',
|
|
@@ -2076,6 +2508,14 @@
|
|
|
2076
2508
|
|
|
2077
2509
|
var p = el('p');
|
|
2078
2510
|
var det = resolveDetails(cfg);
|
|
2511
|
+
/* SPEC V1.16 §1.3 — «Подробнее» and one of the link rows point at the same
|
|
2512
|
+
page: drop the in-text copy rather than print the address twice. Asked
|
|
2513
|
+
through detailsKind() so the shape drawn here is the same one signature()
|
|
2514
|
+
watches for changes. The links are resolved for THIS render's language,
|
|
2515
|
+
exactly as buildLinksRow does below, so a row that no visitor of this
|
|
2516
|
+
banner can see (no label in their language) never silences the link
|
|
2517
|
+
they can. */
|
|
2518
|
+
if (detailsKind(cfg, LANG) === 'hide') det = { kind: 'hide', href: null };
|
|
2079
2519
|
if (det.kind === 'hide') {
|
|
2080
2520
|
p.appendChild(document.createTextNode(T.bannerText));
|
|
2081
2521
|
} else {
|
|
@@ -2136,6 +2576,18 @@
|
|
|
2136
2576
|
actions.appendChild(custom);
|
|
2137
2577
|
b.appendChild(actions);
|
|
2138
2578
|
|
|
2579
|
+
/* SPEC V1.16 §1.3 — the link row, under the buttons in all three layouts.
|
|
2580
|
+
Appended to the BANNER rather than to the body, so it sits after the
|
|
2581
|
+
actions in DOM (and reading) order everywhere: box and modal stack, and
|
|
2582
|
+
the bar's `width:100%` on .ck-links forces it onto its own line under
|
|
2583
|
+
the row instead of squeezing in beside the buttons.
|
|
2584
|
+
|
|
2585
|
+
The bar's mobile media query re-orders its children explicitly (body
|
|
2586
|
+
contents `order:1`, actions `order:2`); the row carries `order:3` there
|
|
2587
|
+
so it stays below the buttons rather than jumping above them. */
|
|
2588
|
+
var blinks = buildLinksRow(cfg, 'ck-links--banner');
|
|
2589
|
+
if (blinks) b.appendChild(blinks);
|
|
2590
|
+
|
|
2139
2591
|
// box/modal: the foot follows the buttons (see the note above).
|
|
2140
2592
|
if (foot && lay.type !== 'bar') b.appendChild(foot);
|
|
2141
2593
|
|
|
@@ -2147,6 +2599,21 @@
|
|
|
2147
2599
|
root.appendChild(b);
|
|
2148
2600
|
}
|
|
2149
2601
|
|
|
2602
|
+
/* SPEC V1.16 §1.2 — the «Дополнительно» card, or null when the site supplied
|
|
2603
|
+
no text for this language. T.extraText has already been through the whole
|
|
2604
|
+
merge (dictionary '' <- __ckLocales <- config.texts[lang] <- [base]), so
|
|
2605
|
+
the only question left here is whether anything survived it. */
|
|
2606
|
+
function buildExtra() {
|
|
2607
|
+
var nodes = renderRich(T.extraText);
|
|
2608
|
+
if (!nodes.length) return null;
|
|
2609
|
+
var box = el('div', 'ck-extra');
|
|
2610
|
+
box.appendChild(el('h3', 'ck-extra__title', T.extraTitle));
|
|
2611
|
+
var bodyEl = el('div', 'ck-extra__body');
|
|
2612
|
+
for (var i = 0; i < nodes.length; i++) bodyEl.appendChild(nodes[i]);
|
|
2613
|
+
box.appendChild(bodyEl);
|
|
2614
|
+
return box;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2150
2617
|
function buildPanel(cfg) {
|
|
2151
2618
|
var scrim = el('div', 'ck-panel-scrim ck-hidden');
|
|
2152
2619
|
scrim.setAttribute('aria-hidden', 'true');
|
|
@@ -2174,6 +2641,18 @@
|
|
|
2174
2641
|
var body = el('div', 'ck-panel__body');
|
|
2175
2642
|
var cats = ['necessary'].concat(activeOptIn(cfg));
|
|
2176
2643
|
for (var i = 0; i < cats.length; i++) body.appendChild(buildCategory(cfg, cats[i]));
|
|
2644
|
+
|
|
2645
|
+
/* SPEC V1.16 §1.2 — «Дополнительно», after the categories and their service
|
|
2646
|
+
lists, before the buttons. INSIDE .ck-panel__body, which is the scrolling
|
|
2647
|
+
region: an operator block runs to a paragraph or three, and put between
|
|
2648
|
+
the body and the foot it would be clipped on a short viewport with no way
|
|
2649
|
+
to reach the end of it.
|
|
2650
|
+
|
|
2651
|
+
Rendered only when the resolved extraText is a non-empty string — every
|
|
2652
|
+
dictionary ships '', so a site that never configured one draws nothing
|
|
2653
|
+
and the panel is byte-for-byte what 0.5.14 drew. */
|
|
2654
|
+
var extra = buildExtra();
|
|
2655
|
+
if (extra) body.appendChild(extra);
|
|
2177
2656
|
p.appendChild(body);
|
|
2178
2657
|
|
|
2179
2658
|
var foot = el('div', 'ck-panel__foot');
|
|
@@ -2201,6 +2680,11 @@
|
|
|
2201
2680
|
foot.appendChild(save);
|
|
2202
2681
|
foot.appendChild(acc);
|
|
2203
2682
|
foot.appendChild(rej);
|
|
2683
|
+
// SPEC V1.16 §1.3 — the operator's own addresses, under the panel's buttons
|
|
2684
|
+
// and above the attribution, so the bottom of the panel reads: what you can
|
|
2685
|
+
// do, then where to read more, then who made this.
|
|
2686
|
+
var plinks = buildLinksRow(cfg, 'ck-links--panel');
|
|
2687
|
+
if (plinks) foot.appendChild(plinks);
|
|
2204
2688
|
// Same attribution foot as the banner: mark and credit sign the bottom,
|
|
2205
2689
|
// below the action buttons, never the panel heading.
|
|
2206
2690
|
var pfoot = buildPoweredBy(cfg);
|
|
@@ -2757,7 +3241,14 @@
|
|
|
2757
3241
|
// Structural: link / button / nothing are three different DOM shapes,
|
|
2758
3242
|
// and mount() is one-shot. Buttons and colours are deliberately NOT
|
|
2759
3243
|
// here — they are token values and restyle in place.
|
|
2760
|
-
|
|
3244
|
+
/* SPEC V1.16 §1.3 — the EFFECTIVE kind, not the configured one: a config
|
|
3245
|
+
whose policyUrl grows into (or out of) one of its own link rows flips
|
|
3246
|
+
the shape between link and nothing while resolveDetails still answers
|
|
3247
|
+
'policy'. Without the fold, a SaaS config arriving after the first
|
|
3248
|
+
mount would leave the duplicate on screen. The language is the config's
|
|
3249
|
+
own, resolved the way mount() resolves it, so the two sides of a
|
|
3250
|
+
comparison are always read in the same language. */
|
|
3251
|
+
detailsKind(c),
|
|
2761
3252
|
/* SPEC V1.12 §3 — services are STRUCTURAL: each one adds a row with its
|
|
2762
3253
|
own switch to the panel, and mount() is one-shot. Without this a SaaS
|
|
2763
3254
|
config that arrives after the first mount (the second, idempotent
|
|
@@ -2770,10 +3261,43 @@
|
|
|
2770
3261
|
`purpose` is text inside an existing row and does not justify tearing
|
|
2771
3262
|
the panel down. */
|
|
2772
3263
|
serviceSignature(c),
|
|
3264
|
+
/* SPEC V1.16 §1 — custom texts are STRUCTURAL for the same reason
|
|
3265
|
+
services are: the «Дополнительно» card and the link row are DOM nodes
|
|
3266
|
+
that appear and disappear, and mount() is one-shot. A SaaS config that
|
|
3267
|
+
arrives after the first render (the second, idempotent init()) would
|
|
3268
|
+
otherwise leave a panel with no operator block on a site that has one.
|
|
3269
|
+
|
|
3270
|
+
Not the texts themselves, which can be long: the LANGUAGE KEYS that
|
|
3271
|
+
carry overrides, whether an extra block would render, and the links'
|
|
3272
|
+
identity. A reworded sentence inside a block that is already on screen
|
|
3273
|
+
does not justify tearing the panel down mid-decision. */
|
|
3274
|
+
textsSignature(c),
|
|
2773
3275
|
brandSignature(c)
|
|
2774
3276
|
].join('|');
|
|
2775
3277
|
}
|
|
2776
3278
|
|
|
3279
|
+
function textsSignature(cfg) {
|
|
3280
|
+
var texts = (cfg && cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
|
|
3281
|
+
? cfg.texts : null;
|
|
3282
|
+
if (!texts) return '0';
|
|
3283
|
+
var langs = [];
|
|
3284
|
+
for (var k in texts) {
|
|
3285
|
+
if (!Object.prototype.hasOwnProperty.call(texts, k)) continue;
|
|
3286
|
+
if (LANG_KEY_RE.test(String(k).toLowerCase())) langs.push(String(k).toLowerCase());
|
|
3287
|
+
}
|
|
3288
|
+
langs.sort();
|
|
3289
|
+
var list = texts.links;
|
|
3290
|
+
var ids = [];
|
|
3291
|
+
if (Array.isArray(list)) {
|
|
3292
|
+
for (var i = 0; i < list.length; i++) {
|
|
3293
|
+
var row = list[i];
|
|
3294
|
+
if (row && typeof row === 'object') ids.push(String(row.id || i) + ':' + String(row.url || ''));
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
if (!langs.length && !ids.length) return '0';
|
|
3298
|
+
return langs.join(',') + '/' + ids.join(',');
|
|
3299
|
+
}
|
|
3300
|
+
|
|
2777
3301
|
function serviceSignature(cfg) {
|
|
2778
3302
|
var list = cfg && cfg.services;
|
|
2779
3303
|
if (!list || !Array.isArray(list) || !list.length) return '0';
|
|
@@ -2816,7 +3340,10 @@
|
|
|
2816
3340
|
|
|
2817
3341
|
var table = localeTable(); // read at render time
|
|
2818
3342
|
LANG = resolveLang(cfg && cfg.language, table);
|
|
2819
|
-
|
|
3343
|
+
// SPEC V1.16 §1.1 — the config is the third merge layer, so it must be
|
|
3344
|
+
// handed in here: this is the only place that knows which language the
|
|
3345
|
+
// banner actually resolved to, which is what the override is keyed by.
|
|
3346
|
+
T = buildStrings(LANG, table, cfg);
|
|
2820
3347
|
|
|
2821
3348
|
host = document.getElementById('ck-root');
|
|
2822
3349
|
if (!host) {
|
|
@@ -2952,6 +3479,22 @@
|
|
|
2952
3479
|
servicePurpose: servicePurpose,
|
|
2953
3480
|
groupCountLabel: groupCountLabel,
|
|
2954
3481
|
serviceSignature: serviceSignature,
|
|
3482
|
+
/* SPEC V1.16 §1 — the pure halves of the custom-text feature, so the
|
|
3483
|
+
cabinet's live preview quotes the same rules the banner paints and the
|
|
3484
|
+
server's validator can be tested against the same contract.
|
|
3485
|
+
|
|
3486
|
+
renderRich() is the exception to «nothing here touches the DOM»: it
|
|
3487
|
+
BUILDS nodes, so it needs document.createElement. That is deliberate —
|
|
3488
|
+
the whole point of the function is that there is no innerHTML anywhere
|
|
3489
|
+
in it, and a version that returned a string could not promise that. It
|
|
3490
|
+
is still pure in every other sense: same input, same nodes, no reads of
|
|
3491
|
+
module state. In a context with no `document` it is simply not called. */
|
|
3492
|
+
renderRich: renderRich,
|
|
3493
|
+
resolveLinks: resolveLinks,
|
|
3494
|
+
linkLabel: linkLabel,
|
|
3495
|
+
detailsDuplicatesLink: detailsDuplicatesLink,
|
|
3496
|
+
detailsKind: detailsKind,
|
|
3497
|
+
textsSignature: textsSignature,
|
|
2955
3498
|
signature: signature
|
|
2956
3499
|
};
|
|
2957
3500
|
// The page-font probe reads the DOM, so it is not part of the pure block —
|
|
@@ -2971,6 +3514,11 @@
|
|
|
2971
3514
|
// this file in Node is a no-op rather than a throw (mirrors the core).
|
|
2972
3515
|
if (typeof document === 'undefined') return;
|
|
2973
3516
|
|
|
3517
|
+
// Claim the page for this copy (SPEC §1.9, guard at the top of the file).
|
|
3518
|
+
// Here rather than at the top: only now does this copy take ownership of the
|
|
3519
|
+
// listeners and the mount, and only that ownership is worth locking.
|
|
3520
|
+
try { if (typeof window !== 'undefined') { window.__ckUiLoaded = true; } } catch (e) { /* noop */ }
|
|
3521
|
+
|
|
2974
3522
|
document.addEventListener('ck:init', function (e) {
|
|
2975
3523
|
var d = (e && e.detail) || {};
|
|
2976
3524
|
var cfg = d.config || safeConfig();
|