@ecomconsult/consentkit 0.3.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -20
- package/npm/core.mjs +4 -1
- package/npm/index.cjs +4 -1
- package/npm/index.d.ts +228 -2
- package/npm/index.mjs +14 -2
- package/npm/internal-stub.mjs +12 -1
- package/package.json +1 -1
- package/src/ck-core.js +524 -38
- package/src/ck-debug.js +137 -3
- package/src/ck-saas.js +36 -1
- package/src/ck-ui-branding.js +511 -0
- package/src/ck-ui.js +530 -457
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/* ConsentKit branding extension — logo + "powered by" line for the banner and panel.
|
|
2
|
+
|
|
3
|
+
OPTIONAL BY DESIGN. src/ck-ui.js renders branding only when this file has
|
|
4
|
+
registered itself; with the file absent it draws no logo, no attribution and
|
|
5
|
+
no branding CSS, and nothing errors. That is the whole point of the split:
|
|
6
|
+
an integrator who never sets `config.branding` should not ship ~19 KB of SVG
|
|
7
|
+
sanitiser and brand styling to every visitor, and tools/build-inline.mjs
|
|
8
|
+
--no-branding now drops the code rather than only the config object.
|
|
9
|
+
|
|
10
|
+
LOAD ORDER: before ck-ui.js. Registration has to happen before the first
|
|
11
|
+
mount(), and a page that calls ConsentKit.init() straight after ck-ui.js
|
|
12
|
+
mounts during the ck:init dispatch — earlier than any later-loading file
|
|
13
|
+
could register. Loading before ck-ui.js is unconditionally safe.
|
|
14
|
+
|
|
15
|
+
<script src="ck-core.js"></script>
|
|
16
|
+
<script src="ck-locales.js"></script>
|
|
17
|
+
<script src="ck-ui-branding.js"></script> <!-- optional -->
|
|
18
|
+
<script src="ck-ui.js"></script>
|
|
19
|
+
|
|
20
|
+
The contract with ck-ui.js is the object published on
|
|
21
|
+
window.ConsentKit._uiExtensions.branding, below. ck-ui.js owns the DOM
|
|
22
|
+
helpers and the localised strings and passes them in per call as `host`
|
|
23
|
+
({ el, str, T }), so the dictionary, STR_KEYS and the en fallback stay in one
|
|
24
|
+
place and this file never reaches into ck-ui's closure.
|
|
25
|
+
|
|
26
|
+
Copyright (c) 2026 E-COM CONSULT PLUS. MIT License — see LICENSE. */
|
|
27
|
+
(function () {
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
/* Restraint is the design rule here, not a matter of taste.
|
|
31
|
+
|
|
32
|
+
A consent banner is shown to every visitor of the site that installs it,
|
|
33
|
+
and it asks them a legal question. An agency logo, agency colours and an
|
|
34
|
+
attribution line all at once make it read as the agency's dialogue rather
|
|
35
|
+
than the site's own — visitors trust it less, and the banner competes with
|
|
36
|
+
the page it sits on.
|
|
37
|
+
|
|
38
|
+
So: everything in `branding` is off unless asked for, and the recommended
|
|
39
|
+
shape is one small logo (16–20px) OR one attribution line — with
|
|
40
|
+
theme.accent left matching the HOST SITE, never the agency's colour.
|
|
41
|
+
Nothing here may outweigh the consent buttons. */
|
|
42
|
+
|
|
43
|
+
/* SVG sanitiser.
|
|
44
|
+
|
|
45
|
+
branding.logo may be a raw SVG string coming from a server-rendered config
|
|
46
|
+
or a WordPress admin field. That is untrusted input, so it never reaches
|
|
47
|
+
innerHTML: `<svg onload=...>` executes on insertion, and so do SMIL
|
|
48
|
+
`<animate onbegin=...>` and `<foreignObject><img onerror=...>`.
|
|
49
|
+
|
|
50
|
+
Approach chosen: parse inert, then REBUILD rather than strip-and-adopt.
|
|
51
|
+
DOMParser with 'image/svg+xml' yields a detached, non-live document where
|
|
52
|
+
nothing runs. We then walk that tree and construct a brand-new tree with
|
|
53
|
+
createElementNS, copying across only allowlisted tags and attributes.
|
|
54
|
+
|
|
55
|
+
Rebuilding is what makes this safe rather than merely careful. The rejected
|
|
56
|
+
alternative — importNode/appendChild the parsed tree after deleting bad
|
|
57
|
+
attributes — arms every inline handler at the moment of adoption, so a
|
|
58
|
+
single missed attribute name is live code. Here an attribute we do not
|
|
59
|
+
recognise is simply never written, so the failure mode is a missing
|
|
60
|
+
decoration, not script execution. Allowlists (closed) beat blocklists
|
|
61
|
+
(open-ended) for the same reason.
|
|
62
|
+
|
|
63
|
+
Deliberately excluded, each for a concrete reason:
|
|
64
|
+
script - obvious
|
|
65
|
+
foreignObject - escape hatch back into full HTML
|
|
66
|
+
use, image - can reference/fetch external documents
|
|
67
|
+
a - javascript: navigation inside the logo
|
|
68
|
+
style - CSS escapes, and it would leak out of the
|
|
69
|
+
logo into our own shadow-root styling
|
|
70
|
+
animate/set/animateTransform - SMIL takes an attributeName and can drive
|
|
71
|
+
arbitrary attributes, plus on* timing events
|
|
72
|
+
|
|
73
|
+
Anything unexpected bails to null (no logo) rather than partially rendering. */
|
|
74
|
+
|
|
75
|
+
/* Local copy of ck-ui's str(): this file is loaded before ck-ui.js and must
|
|
76
|
+
not reach into its closure. Same contract — trimmed string or null. */
|
|
77
|
+
function str(v) {
|
|
78
|
+
return (typeof v === 'string' && v.trim()) ? v.trim() : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
82
|
+
|
|
83
|
+
var SVG_TAGS = {
|
|
84
|
+
svg: 1, g: 1, path: 1, circle: 1, ellipse: 1, rect: 1, line: 1,
|
|
85
|
+
polyline: 1, polygon: 1, defs: 1, title: 1, desc: 1,
|
|
86
|
+
lineargradient: 1, radialgradient: 1, stop: 1, clippath: 1, mask: 1
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Presentation/geometry only. No href/xlink:href in any form, no on* events.
|
|
90
|
+
var SVG_ATTRS = {
|
|
91
|
+
viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio',
|
|
92
|
+
xmlns: 'xmlns', version: 'version',
|
|
93
|
+
d: 'd', fill: 'fill', 'fill-rule': 'fill-rule', 'fill-opacity': 'fill-opacity',
|
|
94
|
+
'clip-rule': 'clip-rule', 'clip-path': 'clip-path', mask: 'mask',
|
|
95
|
+
stroke: 'stroke', 'stroke-width': 'stroke-width', 'stroke-linecap': 'stroke-linecap',
|
|
96
|
+
'stroke-linejoin': 'stroke-linejoin', 'stroke-dasharray': 'stroke-dasharray',
|
|
97
|
+
'stroke-dashoffset': 'stroke-dashoffset', 'stroke-opacity': 'stroke-opacity',
|
|
98
|
+
'stroke-miterlimit': 'stroke-miterlimit',
|
|
99
|
+
opacity: 'opacity', transform: 'transform',
|
|
100
|
+
x: 'x', y: 'y', x1: 'x1', y1: 'y1', x2: 'x2', y2: 'y2',
|
|
101
|
+
cx: 'cx', cy: 'cy', r: 'r', rx: 'rx', ry: 'ry',
|
|
102
|
+
width: 'width', height: 'height', points: 'points',
|
|
103
|
+
offset: 'offset', 'stop-color': 'stop-color', 'stop-opacity': 'stop-opacity',
|
|
104
|
+
gradientunits: 'gradientUnits', gradienttransform: 'gradientTransform',
|
|
105
|
+
spreadmethod: 'spreadMethod', clippathunits: 'clipPathUnits',
|
|
106
|
+
maskunits: 'maskUnits', maskcontentunits: 'maskContentUnits',
|
|
107
|
+
id: 'id', 'class': 'class'
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/* Every sanitised logo gets a unique id namespace.
|
|
111
|
+
|
|
112
|
+
buildBrandLogo() runs twice per mount (banner + panel head), and doubles
|
|
113
|
+
again when logoDark is set — so one shadow root can hold four copies of the
|
|
114
|
+
same asset. A gradient/clipPath/mask id like "g" would then appear four
|
|
115
|
+
times, and url(#g) resolves to the FIRST match in the tree: the dark logo
|
|
116
|
+
would silently paint with the light logo's gradient stops. Prefixing every
|
|
117
|
+
id per instance, and rewriting the url(#…) references in the same pass,
|
|
118
|
+
keeps each copy self-contained. */
|
|
119
|
+
var svgSeq = 0;
|
|
120
|
+
|
|
121
|
+
// url(#localRef) and plain values only — no url(http…), no javascript:.
|
|
122
|
+
// `prefix` namespaces id definitions and their url(#…) references together.
|
|
123
|
+
function safeAttrValue(name, value, prefix) {
|
|
124
|
+
var v = String(value == null ? '' : value);
|
|
125
|
+
// Strip nothing; reject outright. Control chars are how javascript: is hidden.
|
|
126
|
+
var probe = v.replace(/[\u0000-\u0020\u007f-\u00a0]/g, '').toLowerCase();
|
|
127
|
+
if (probe.indexOf('javascript:') !== -1) return null;
|
|
128
|
+
if (probe.indexOf('data:text') !== -1) return null;
|
|
129
|
+
if (probe.indexOf('&#') !== -1) return null;
|
|
130
|
+
// Any url() must be a same-document fragment reference.
|
|
131
|
+
if (probe.indexOf('url(') !== -1 && !/^url\(#[a-z0-9_.:-]+\)$/i.test(probe)) return null;
|
|
132
|
+
if (name === 'id') {
|
|
133
|
+
if (!/^[a-zA-Z][\w.:-]*$/.test(v)) return null;
|
|
134
|
+
return prefix + v;
|
|
135
|
+
}
|
|
136
|
+
// Rewrite a reference so it points at THIS instance's namespaced definition.
|
|
137
|
+
var m = /^url\(#([\w.:-]+)\)$/.exec(v);
|
|
138
|
+
if (m) return 'url(#' + prefix + m[1] + ')';
|
|
139
|
+
return v;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function rebuildSvgNode(src, out, depth, prefix) {
|
|
143
|
+
if (depth > 24) return false; // pathological nesting
|
|
144
|
+
var kids = src.childNodes;
|
|
145
|
+
for (var i = 0; i < kids.length; i++) {
|
|
146
|
+
var n = kids[i];
|
|
147
|
+
if (n.nodeType === 3) { // text (only inside title/desc)
|
|
148
|
+
var pt = out.nodeName.toLowerCase();
|
|
149
|
+
if (pt === 'title' || pt === 'desc') out.appendChild(document.createTextNode(n.nodeValue));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (n.nodeType !== 1) continue; // drop comments, CDATA, PIs
|
|
153
|
+
var tag = String(n.nodeName || '').toLowerCase();
|
|
154
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_TAGS, tag)) return false; // bail, don't skip
|
|
155
|
+
var fresh = document.createElementNS(SVG_NS, n.nodeName);
|
|
156
|
+
var attrs = n.attributes || [];
|
|
157
|
+
for (var a = 0; a < attrs.length; a++) {
|
|
158
|
+
var an = String(attrs[a].name || '').toLowerCase();
|
|
159
|
+
if (/^on/i.test(an)) return false; // event handler present -> reject whole logo
|
|
160
|
+
if (an === 'href' || an === 'xlink:href' || an.indexOf('xlink') === 0) return false;
|
|
161
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue; // unknown -> just omit
|
|
162
|
+
var val = safeAttrValue(an, attrs[a].value, prefix);
|
|
163
|
+
if (val === null) continue;
|
|
164
|
+
try { fresh.setAttribute(SVG_ATTRS[an], val); } catch (e) { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
if (!rebuildSvgNode(n, fresh, depth + 1, prefix)) return false;
|
|
167
|
+
out.appendChild(fresh);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Raw SVG string -> freshly built, safe <svg> element, or null.
|
|
173
|
+
function sanitizeSvg(markup) {
|
|
174
|
+
var s = str(markup);
|
|
175
|
+
if (!s || s.length > 512 * 1024) return null;
|
|
176
|
+
if (!/^\s*<svg[\s>]/i.test(s)) return null; // must be an SVG root
|
|
177
|
+
var doc;
|
|
178
|
+
try {
|
|
179
|
+
doc = new DOMParser().parseFromString(s, 'image/svg+xml');
|
|
180
|
+
} catch (e) { return null; }
|
|
181
|
+
if (!doc) return null;
|
|
182
|
+
if (doc.getElementsByTagName('parsererror').length) return null;
|
|
183
|
+
var srcRoot = doc.documentElement;
|
|
184
|
+
if (!srcRoot || String(srcRoot.nodeName).toLowerCase() !== 'svg') return null;
|
|
185
|
+
|
|
186
|
+
// Unique per sanitised instance, so four copies of one asset never collide.
|
|
187
|
+
var prefix = 'ck' + (++svgSeq) + '-';
|
|
188
|
+
|
|
189
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
190
|
+
var ra = srcRoot.attributes || [];
|
|
191
|
+
for (var i = 0; i < ra.length; i++) {
|
|
192
|
+
var an = String(ra[i].name || '').toLowerCase();
|
|
193
|
+
if (/^on/i.test(an)) return null;
|
|
194
|
+
if (an.indexOf('xlink') === 0 || an === 'href') return null;
|
|
195
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue;
|
|
196
|
+
var val = safeAttrValue(an, ra[i].value, prefix);
|
|
197
|
+
if (val === null) continue;
|
|
198
|
+
try { svg.setAttribute(SVG_ATTRS[an], val); } catch (e) {}
|
|
199
|
+
}
|
|
200
|
+
if (!rebuildSvgNode(srcRoot, svg, 0, prefix)) return null;
|
|
201
|
+
return svg;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Image-source logos.
|
|
205
|
+
Only http(s) and image data: URIs. data:text/html is a navigation/XSS
|
|
206
|
+
vector via <img>-adjacent contexts, and any other scheme is rejected.
|
|
207
|
+
|
|
208
|
+
NOTE FOR INTEGRATORS: an https:// logo is an external network request that
|
|
209
|
+
fires BEFORE the visitor has consented to anything. It leaks IP, User-Agent
|
|
210
|
+
and Referer to whoever hosts the file. ConsentKit therefore recommends an
|
|
211
|
+
inline SVG string or a data: URI, both of which are entirely local. An
|
|
212
|
+
external URL still works — it is the integrator's call, made knowingly —
|
|
213
|
+
and we send referrerpolicy=no-referrer to reduce what leaks. */
|
|
214
|
+
function safeImgSrc(value) {
|
|
215
|
+
var v = str(value);
|
|
216
|
+
if (!v) return null;
|
|
217
|
+
if (/^https?:\/\//i.test(v)) return v;
|
|
218
|
+
if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif|avif)[;,]/i.test(v)) return v;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Default 18px and a 32px ceiling: the logo is a signature, not a header.
|
|
223
|
+
// Anything taller starts competing with the banner title.
|
|
224
|
+
function clampLogoHeight(v) {
|
|
225
|
+
var n = (typeof v === 'number') ? v : parseFloat(v);
|
|
226
|
+
if (!isFinite(n)) return 18;
|
|
227
|
+
if (n < 14) return 14;
|
|
228
|
+
if (n > 32) return 32;
|
|
229
|
+
return Math.round(n);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Only http(s) links are made clickable; javascript:/data: never become hrefs.
|
|
233
|
+
function safeLinkUrl(value) {
|
|
234
|
+
var v = str(value);
|
|
235
|
+
if (!v) return null;
|
|
236
|
+
return /^https?:\/\//i.test(v) ? v : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function brandingCfg(cfg) {
|
|
240
|
+
var b = cfg && cfg.branding;
|
|
241
|
+
return (b && typeof b === 'object' && !Array.isArray(b)) ? b : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// One logo node (inline SVG or <img>), already sanitised. null when unusable.
|
|
245
|
+
function buildLogoNode(source, alt, decorative, host) {
|
|
246
|
+
var el = host.el, str = host.str;
|
|
247
|
+
if (!source) return null;
|
|
248
|
+
var node = null;
|
|
249
|
+
var s = str(source);
|
|
250
|
+
if (!s) return null;
|
|
251
|
+
|
|
252
|
+
if (/^\s*</.test(s)) {
|
|
253
|
+
node = sanitizeSvg(s); // raw markup -> rebuilt SVG
|
|
254
|
+
if (node) node.classList.add('ck-brand__logo');
|
|
255
|
+
} else {
|
|
256
|
+
var src = safeImgSrc(s);
|
|
257
|
+
if (!src) return null;
|
|
258
|
+
node = el('img', 'ck-brand__logo');
|
|
259
|
+
node.setAttribute('referrerpolicy', 'no-referrer');
|
|
260
|
+
node.setAttribute('decoding', 'async');
|
|
261
|
+
node.src = src;
|
|
262
|
+
node.alt = decorative ? '' : (alt || '');
|
|
263
|
+
}
|
|
264
|
+
// The SVG carries no accessible name of its own; the wrapper supplies one
|
|
265
|
+
// (or hides it, when a sibling already names the logo).
|
|
266
|
+
if (node && node.nodeName.toLowerCase() === 'svg') {
|
|
267
|
+
node.setAttribute('aria-hidden', 'true');
|
|
268
|
+
node.setAttribute('focusable', 'false');
|
|
269
|
+
}
|
|
270
|
+
return node;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* Logo block for a banner/panel header.
|
|
274
|
+
|
|
275
|
+
Dark theme: branding.logoDark, when supplied, is rendered as a second node
|
|
276
|
+
and swapped purely in CSS. When it is absent the single main logo shows in
|
|
277
|
+
both themes — which is why the shipped ECOM Consult asset (wordmark
|
|
278
|
+
fill="white", built for dark backgrounds) belongs in logoDark, with a
|
|
279
|
+
dark-ink variant in logo. An <img>/data: logo cannot be recoloured by our
|
|
280
|
+
CSS at all, so two assets are the only route there; an inline SVG could in
|
|
281
|
+
principle inherit currentColor, but only if the asset is authored that way. */
|
|
282
|
+
function buildBrandLogo(cfg, host) {
|
|
283
|
+
var el = host.el, str = host.str;
|
|
284
|
+
var b = brandingCfg(cfg);
|
|
285
|
+
if (!b) return null;
|
|
286
|
+
|
|
287
|
+
var alt = str(b.logoAlt) || '';
|
|
288
|
+
var main = buildLogoNode(b.logo, alt, false, host);
|
|
289
|
+
if (!main) return null; // no valid logo -> render nothing
|
|
290
|
+
|
|
291
|
+
var dark = buildLogoNode(b.logoDark, alt, false, host);
|
|
292
|
+
|
|
293
|
+
var wrap = el('div', 'ck-brand');
|
|
294
|
+
if (dark) {
|
|
295
|
+
wrap.classList.add('ck-brand__has-dark');
|
|
296
|
+
main.classList.add('ck-brand__light');
|
|
297
|
+
dark.classList.add('ck-brand__dark');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
var link = safeLinkUrl(b.logoUrl);
|
|
301
|
+
var host_ = wrap;
|
|
302
|
+
if (link) {
|
|
303
|
+
var a = el('a', 'ck-brand__link');
|
|
304
|
+
a.href = link;
|
|
305
|
+
a.target = '_blank';
|
|
306
|
+
a.rel = 'noopener noreferrer';
|
|
307
|
+
// Links are focusable by nature; the accessible name comes from logoAlt.
|
|
308
|
+
a.setAttribute('aria-label', alt || 'ConsentKit');
|
|
309
|
+
host_ = a;
|
|
310
|
+
wrap.appendChild(a);
|
|
311
|
+
}
|
|
312
|
+
host_.appendChild(main);
|
|
313
|
+
if (dark) host_.appendChild(dark);
|
|
314
|
+
|
|
315
|
+
// A non-linked logo must not be a tab stop. The <svg>/<img> is aria-hidden
|
|
316
|
+
// or alt="", so a visually-hidden-free text alternative is supplied here
|
|
317
|
+
// for the image case only when it is not already announced by the <img> alt.
|
|
318
|
+
if (!link && alt && main.nodeName.toLowerCase() === 'svg') {
|
|
319
|
+
wrap.setAttribute('role', 'img');
|
|
320
|
+
wrap.setAttribute('aria-label', alt);
|
|
321
|
+
}
|
|
322
|
+
return wrap;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Per-mount CSS for logo height + the dark/light swap. Mirrors buildThemeCss.
|
|
326
|
+
function buildBrandCss(cfg) {
|
|
327
|
+
var b = brandingCfg(cfg);
|
|
328
|
+
if (!b) return '';
|
|
329
|
+
var h = clampLogoHeight(b.logoHeight);
|
|
330
|
+
var out = [':host{--ck-logo-h:' + h + 'px}'];
|
|
331
|
+
|
|
332
|
+
var theme = (cfg && cfg.theme) || {};
|
|
333
|
+
var mode = theme.mode;
|
|
334
|
+
if (mode !== 'light' && mode !== 'dark') mode = 'auto';
|
|
335
|
+
|
|
336
|
+
// Same cascade shape as buildThemeCss so the logo always agrees with the
|
|
337
|
+
// palette: auto mode follows prefers-color-scheme but a forced .ck-mode-light
|
|
338
|
+
// still wins, and .ck-mode-dark forces the dark asset outright.
|
|
339
|
+
function swap(prefix) {
|
|
340
|
+
return prefix + ' .ck-brand__has-dark .ck-brand__light{display:none}\n' +
|
|
341
|
+
prefix + ' .ck-brand__has-dark .ck-brand__dark{display:block}';
|
|
342
|
+
}
|
|
343
|
+
if (mode === 'auto') {
|
|
344
|
+
out.push('@media (prefers-color-scheme: dark){\n' +
|
|
345
|
+
swap(':host(:not(.ck-mode-light))') + '\n}');
|
|
346
|
+
}
|
|
347
|
+
out.push(swap(':host(.ck-mode-dark)'));
|
|
348
|
+
return out.join('\n');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* Powered-by line. true -> localised default; object -> caller's text/url.
|
|
352
|
+
Rendered after the actions in DOM order and styled quiet on purpose. */
|
|
353
|
+
function buildPoweredBy(cfg, host) {
|
|
354
|
+
var el = host.el, str = host.str, T = host.T;
|
|
355
|
+
var b = brandingCfg(cfg);
|
|
356
|
+
if (!b) return null;
|
|
357
|
+
var pb = b.poweredBy;
|
|
358
|
+
if (!pb) return null; // false/undefined -> nothing
|
|
359
|
+
|
|
360
|
+
var text, url = null;
|
|
361
|
+
if (pb === true) {
|
|
362
|
+
text = T.poweredBy; // en fallback guaranteed by STR_KEYS
|
|
363
|
+
} else if (typeof pb === 'object' && !Array.isArray(pb)) {
|
|
364
|
+
text = str(pb.text) || T.poweredBy;
|
|
365
|
+
url = safeLinkUrl(pb.url);
|
|
366
|
+
} else {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
var p = el('p', 'ck-powered');
|
|
371
|
+
if (url) {
|
|
372
|
+
var a = el('a', null, text);
|
|
373
|
+
a.href = url;
|
|
374
|
+
a.target = '_blank';
|
|
375
|
+
a.rel = 'noopener noreferrer';
|
|
376
|
+
p.appendChild(a);
|
|
377
|
+
} else {
|
|
378
|
+
p.appendChild(document.createTextNode(text));
|
|
379
|
+
}
|
|
380
|
+
return p;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ------------------------------------------------------- static stylesheet */
|
|
384
|
+
|
|
385
|
+
/* The branding rules, returned to ck-ui.js to append to its base sheet. They
|
|
386
|
+
live here rather than in ck-ui.js so a build without this file carries no
|
|
387
|
+
dead .ck-brand / .ck-foot / .ck-powered CSS either. */
|
|
388
|
+
function css() {
|
|
389
|
+
return [
|
|
390
|
+
/* min-width:0 so a wide logo shrinks rather than shoving the close button
|
|
391
|
+
off. Scoped to :has(.ck-brand) — applying it unconditionally changes the
|
|
392
|
+
header block's flex sizing (480px -> 518px) on unbranded panels too, which
|
|
393
|
+
would break byte-for-byte backward compatibility. Browsers without :has()
|
|
394
|
+
simply keep today's sizing; the logo is width-capped at 160px regardless,
|
|
395
|
+
so the close button still has room. */
|
|
396
|
+
'.ck-panel__head>div:first-child:has(.ck-brand){flex:1 1 auto;min-width:0}',
|
|
397
|
+
/* ---- branding: logo + powered-by ----
|
|
398
|
+
The logo sits inline with the title in a flex row. That was chosen over a
|
|
399
|
+
separate band above the heading because .ck-banner--bar is a single
|
|
400
|
+
vertically-centred flex row: a stacked logo adds a height band there and
|
|
401
|
+
nowhere else, so bar/box/modal would drift apart. Inline keeps one rule
|
|
402
|
+
for all three layouts and leaves existing margins untouched — .ck-brand
|
|
403
|
+
carries the whole gap, h2 keeps its own margin. */
|
|
404
|
+
'.ck-brand{display:flex;align-items:center;gap:10px;margin:0 0 8px}',
|
|
405
|
+
/* Attribution foot of the banner: logo + credit on one muted line below
|
|
406
|
+
the buttons. flex-basis 100% keeps it on its own row in the bar layout,
|
|
407
|
+
where the actions sit beside the text.
|
|
408
|
+
|
|
409
|
+
The mark is desaturated here rather than shipped as a second grey asset:
|
|
410
|
+
an agency logo in full brand colour reads as a second call to action
|
|
411
|
+
competing with the consent buttons. grayscale() flattens the hue and the
|
|
412
|
+
opacity lifts it off pure black, so it sits at signature weight in both
|
|
413
|
+
themes without the integrator preparing anything. */
|
|
414
|
+
'.ck-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
|
|
415
|
+
'margin:14px 0 0}',
|
|
416
|
+
'.ck-foot .ck-brand__logo,.ck-foot .ck-brand__logo svg{',
|
|
417
|
+
'filter:grayscale(1);opacity:.55}',
|
|
418
|
+
'.ck-foot .ck-brand__link:hover .ck-brand__logo,',
|
|
419
|
+
'.ck-foot .ck-brand__link:focus-visible .ck-brand__logo{opacity:.8}',
|
|
420
|
+
'.ck-foot .ck-brand{margin:0}',
|
|
421
|
+
/* Beats the flex:1 1 100% the standalone .ck-powered carries (it needs a
|
|
422
|
+
full row of its own when there is no logo beside it). */
|
|
423
|
+
'.ck-foot p.ck-powered,.ck-banner .ck-foot p.ck-powered{margin:0;flex:0 1 auto}',
|
|
424
|
+
/* In the panel the foot shares a flex row with the action buttons, so it
|
|
425
|
+
claims a row of its own below them. */
|
|
426
|
+
'.ck-panel__foot .ck-foot{flex:1 1 100%;margin:2px 0 0}',
|
|
427
|
+
'.ck-brand__logo{display:block;width:auto;max-width:160px;height:var(--ck-logo-h,24px);',
|
|
428
|
+
'flex:none;object-fit:contain}',
|
|
429
|
+
'.ck-brand__logo svg{display:block;width:auto;height:100%;max-width:160px}',
|
|
430
|
+
'.ck-brand a.ck-brand__link{display:inline-flex;align-items:center;text-decoration:none;flex:none}',
|
|
431
|
+
/* Dark-variant swap is CSS-driven, mirroring buildThemeCss()'s cascade
|
|
432
|
+
exactly (same three selectors, same :not(.ck-mode-light) guard). Reading
|
|
433
|
+
the theme in JS would desync in auto mode and would not follow a live
|
|
434
|
+
system theme flip. */
|
|
435
|
+
'.ck-brand__dark{display:none}',
|
|
436
|
+
'.ck-brand__has-dark .ck-brand__light{display:block}',
|
|
437
|
+
|
|
438
|
+
/* ---- powered-by ----
|
|
439
|
+
Deliberately quiet: muted colour, 12px, normal weight, and it comes after
|
|
440
|
+
the action row in DOM order. It must not compete with the consent buttons. */
|
|
441
|
+
/* .ck-banner p sets font-size:14px at equal specificity and appears later in
|
|
442
|
+
this sheet, so it would win over a bare .ck-powered. Qualifying the
|
|
443
|
+
selector keeps the attribution smaller than the button text (14px) without
|
|
444
|
+
reaching for !important. */
|
|
445
|
+
'.ck-powered,.ck-banner p.ck-powered{margin:12px 0 0;font-size:12px;line-height:1.4;',
|
|
446
|
+
'color:var(--ck-muted);flex:1 1 100%;font-weight:400}',
|
|
447
|
+
'.ck-powered a{color:var(--ck-muted);text-decoration:underline}',
|
|
448
|
+
'.ck-panel__foot .ck-powered{margin:0;align-self:center}',
|
|
449
|
+
|
|
450
|
+
/* Narrow bar stacks into a column, so the foot — which lives at the end of
|
|
451
|
+
the text block for the wide side-by-side layout — would sit between the
|
|
452
|
+
question and the buttons answering it. Lift it out of the text block and
|
|
453
|
+
order it last.
|
|
454
|
+
|
|
455
|
+
Its own @media block rather than a line inside ck-ui.js's 560px query:
|
|
456
|
+
the rule only exists when this file does, and a build without branding
|
|
457
|
+
must not carry a dangling selector for an element it never renders. The
|
|
458
|
+
duplicate query costs ~30 bytes and keeps the two sheets separable. */
|
|
459
|
+
'@media (max-width:560px){',
|
|
460
|
+
'.ck-banner--bar .ck-foot{order:3;margin-top:14px}}'
|
|
461
|
+
].join('\n');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/* --------------------------------------------------------------- signature */
|
|
465
|
+
|
|
466
|
+
/* Part of ck-ui.js's mount signature: branding produces DOM, not just
|
|
467
|
+
styling, so a config that gains a logo after the first ck:init has to
|
|
468
|
+
rebuild rather than restyle. Returns '-' when there is no branding config,
|
|
469
|
+
which is what keeps an unbranded page's signature stable. */
|
|
470
|
+
function brandSignature(cfg) {
|
|
471
|
+
var b = brandingCfg(cfg);
|
|
472
|
+
if (!b) return '-';
|
|
473
|
+
var pb = b.poweredBy;
|
|
474
|
+
var pbSig = (pb && typeof pb === 'object')
|
|
475
|
+
? 'o:' + String(pb.text || '') + ':' + String(pb.url || '')
|
|
476
|
+
: String(!!pb);
|
|
477
|
+
// Logos are hashed by length + head so a long data: URI does not bloat the key.
|
|
478
|
+
function tag(v) {
|
|
479
|
+
var s = str(v);
|
|
480
|
+
return s ? (s.length + ':' + s.slice(0, 32)) : '-';
|
|
481
|
+
}
|
|
482
|
+
return [
|
|
483
|
+
tag(b.logo), tag(b.logoDark), String(b.logoAlt || ''),
|
|
484
|
+
String(clampLogoHeight(b.logoHeight)), String(b.logoUrl || ''), pbSig
|
|
485
|
+
].join('~');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* ------------------------------------------------------------- registration */
|
|
489
|
+
|
|
490
|
+
/* Published on the core's namespace so ck-ui.js finds it however the two
|
|
491
|
+
files were loaded. The core creates window.ConsentKit at parse time; when
|
|
492
|
+
this file somehow runs first, a bare object is created and the core merges
|
|
493
|
+
onto it. */
|
|
494
|
+
if (typeof window === 'undefined') return;
|
|
495
|
+
|
|
496
|
+
var CK = window.ConsentKit || (window.ConsentKit = {});
|
|
497
|
+
var ext = CK._uiExtensions || (CK._uiExtensions = {});
|
|
498
|
+
|
|
499
|
+
ext.branding = {
|
|
500
|
+
buildBrandLogo: buildBrandLogo,
|
|
501
|
+
buildPoweredBy: buildPoweredBy,
|
|
502
|
+
buildBrandCss: buildBrandCss,
|
|
503
|
+
brandSignature: brandSignature,
|
|
504
|
+
sanitizeSvg: sanitizeSvg,
|
|
505
|
+
css: css
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// Also exposed under the documented flat name, for integrators who reach for
|
|
509
|
+
// the sanitiser directly rather than through the extension slot.
|
|
510
|
+
window.ConsentKitBranding = ext.branding;
|
|
511
|
+
})();
|