keksi 0.1.4 → 0.1.6

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
@@ -102,9 +102,54 @@ posthog.capture('signup');
102
102
  <keksi-youtube video="aqz-KE-bpKQ" label="Esittelyvideo" poster="/img/poster.jpg"></keksi-youtube>
103
103
  ```
104
104
 
105
- - Shows a placeholder until the visitor clicks it, then loads that one video from `youtube-nocookie.com`.
106
- - With marketing consent, videos load directly.
107
- - `poster` is optional and should be self-hosted. Thumbnails from YouTube would contact Google before consent.
105
+ - Shows a YouTube-style placeholder. Clicking play is the consent: it's remembered, and all videos load from
106
+ `youtube-nocookie.com` from then on.
107
+ - Marketing consent in the banner covers YouTube too, and turning it off withdraws YouTube.
108
+ - With YouTube as the only provider, the banner doesn't show by itself. The `data-keksi-open` link still opens the
109
+ settings for withdrawing.
110
+ - `poster` is optional and should be self-hosted. YouTube's thumbnails come from `i.ytimg.com`, which would contact
111
+ Google before consent.
112
+
113
+ ## Other providers
114
+
115
+ For a provider keksi doesn't have, describe it in `providers`. It's listed in the banner, the settings and the
116
+ policy page, and handled like the built-in ones.
117
+
118
+ ```ts
119
+ export default defineConfig({
120
+ ga: 'G-XXXXXXX',
121
+ providers: [
122
+ {
123
+ name: 'Leadfeeder',
124
+ company: 'Dealfront GmbH',
125
+ privacyPolicy: 'https://www.dealfront.com/privacy-policy/',
126
+ category: 'marketing',
127
+ purpose: { fi: 'Vierailevien yritysten tunnistus.', en: 'Identifying visiting companies.' },
128
+ cookies: [{ name: '_lfa', duration: '2y' }],
129
+ hosts: ['lfeeder.com'],
130
+ script: 'https://sc.lfeeder.com/lftracker_v1_XXXX.js',
131
+ },
132
+ ],
133
+ });
134
+ ```
135
+
136
+ - `script` is loaded after consent in its category.
137
+ - First-party `cookies` are deleted on withdrawal; `*` works as a wildcard. Add `host` to ones set on the provider's
138
+ domain.
139
+ - `hosts` are what `watchTracking` checks for.
140
+ - Adding a provider asks returning visitors again.
141
+
142
+ If the provider needs setup code, leave out `script` and load it yourself:
143
+
144
+ ```ts
145
+ const load = () => {
146
+ if (window.keksi?.consent?.marketing) {
147
+ // inject the script and run its setup
148
+ }
149
+ };
150
+ load();
151
+ document.addEventListener('keksi:change', load);
152
+ ```
108
153
 
109
154
  ## API
110
155
 
@@ -166,5 +211,5 @@ pnpm test
166
211
  pnpm test:e2e # Astro and SvelteKit examples
167
212
  ```
168
213
 
169
- To add a provider: add its data to `src/providers.ts`, a config key with the same id to `KeksiConfig`, and its
214
+ To build in a provider that's used across projects: add its data to `src/providers.ts`, a config key with the same id to `KeksiConfig`, and its
170
215
  loading to `load()` in `src/runtime.ts`.
package/dist/config.d.ts CHANGED
@@ -3,6 +3,9 @@ import type { Category, KeksiConfig, Locale, Provider, RuntimeData } from './typ
3
3
  export declare function defineConfig(config: KeksiConfig): KeksiConfig;
4
4
  export declare function activeProviders(config: KeksiConfig): Provider[];
5
5
  export declare function categoriesOf(config: KeksiConfig): Category[];
6
+ /** False when YouTube is the only provider: a click on a video is the consent, so the banner doesn't show by itself. */
7
+ export declare function needsBanner(config: KeksiConfig): boolean;
8
+ export declare function policyOf(config: KeksiConfig, locale: Locale): string | undefined;
6
9
  export declare function textsFor(config: KeksiConfig, locale: Locale): Texts;
7
10
  /** Changes when a provider is added or removed, which asks everyone again. Ids and texts can change freely. */
8
11
  export declare function version(config: KeksiConfig): string;
package/dist/index.js CHANGED
@@ -33,9 +33,9 @@ var fi = {
33
33
  mo: "kuukausi|kuukautta",
34
34
  d: "p\xE4iv\xE4|p\xE4iv\xE4\xE4",
35
35
  min: "minuutti|minuuttia",
36
- ytText: "Video ladataan YouTubesta. YouTube (Google) asettaa ev\xE4steit\xE4 ja voi siirt\xE4\xE4 tietoja Yhdysvaltoihin.",
37
- ytButton: "N\xE4yt\xE4 video",
38
- ytOpen: "Avaa YouTubessa"
36
+ ytText: "Toistamalla videon hyv\xE4ksyt YouTuben ev\xE4steet.",
37
+ ytButton: "Toista video",
38
+ ytOpen: "Katso YouTubessa"
39
39
  };
40
40
  var sv = {
41
41
  title: "Kakor",
@@ -71,9 +71,9 @@ var sv = {
71
71
  mo: "m\xE5nad|m\xE5nader",
72
72
  d: "dag|dagar",
73
73
  min: "minut|minuter",
74
- ytText: "Videon laddas fr\xE5n YouTube. YouTube (Google) s\xE4tter kakor och kan \xF6verf\xF6ra uppgifter till USA.",
75
- ytButton: "Visa video",
76
- ytOpen: "\xD6ppna p\xE5 YouTube"
74
+ ytText: "Genom att spela upp videon godk\xE4nner du kakor fr\xE5n YouTube.",
75
+ ytButton: "Spela upp video",
76
+ ytOpen: "Titta p\xE5 YouTube"
77
77
  };
78
78
  var en = {
79
79
  title: "Cookies",
@@ -109,9 +109,9 @@ var en = {
109
109
  mo: "month|months",
110
110
  d: "day|days",
111
111
  min: "minute|minutes",
112
- ytText: "The video loads from YouTube. YouTube (Google) sets cookies and may transfer data to the United States.",
113
- ytButton: "Show video",
114
- ytOpen: "Open on YouTube"
112
+ ytText: "By playing the video, you accept cookies from YouTube.",
113
+ ytButton: "Play video",
114
+ ytOpen: "Watch on YouTube"
115
115
  };
116
116
  var texts = { fi, sv, en };
117
117
 
@@ -230,11 +230,21 @@ var posthogHost = (config) => (config.posthog?.host ?? "https://eu.i.posthog.com
230
230
  function activeProviders(config) {
231
231
  return providers.filter(
232
232
  (p) => p.id === "youtube" ? config.youtube : p.id === "posthog" ? config.posthog?.key : list(config[p.id]).length
233
- ).map((p) => p.id === "posthog" && /\/\/us\./.test(posthogHost(config)) ? { ...p, transfer: "us" } : p);
233
+ ).map((p) => p.id === "posthog" && /\/\/us\./.test(posthogHost(config)) ? { ...p, transfer: "us" } : p).concat((config.providers ?? []).map(custom));
234
+ }
235
+ function custom({ purpose, hosts = [], script: _, ...p }) {
236
+ const text = (l) => typeof purpose === "string" ? purpose : purpose[l] ?? Object.values(purpose).find(Boolean) ?? "";
237
+ return { ...p, id: `custom:${p.name}`, purpose: { fi: text("fi"), sv: text("sv"), en: text("en") }, hosts };
234
238
  }
235
239
  function categoriesOf(config) {
236
240
  return ["analytics", "marketing"].filter((c) => activeProviders(config).some((p) => p.category === c));
237
241
  }
242
+ function needsBanner(config) {
243
+ return activeProviders(config).some((p) => p.id !== "youtube");
244
+ }
245
+ function policyOf(config, locale) {
246
+ return typeof config.policyUrl === "string" ? config.policyUrl : config.policyUrl?.[locale];
247
+ }
238
248
  function textsFor(config, locale) {
239
249
  return { ...texts[locale], ...config.texts?.[locale] };
240
250
  }
@@ -256,9 +266,15 @@ function runtimeData(config, locale) {
256
266
  ads: list(config.googleAds),
257
267
  meta: list(config.meta),
258
268
  ph: config.posthog?.key ? { key: config.posthog.key, host: posthogHost(config) } : void 0,
269
+ scripts: {
270
+ analytics: (config.providers ?? []).filter((p) => p.script && p.category === "analytics").map((p) => p.script),
271
+ marketing: (config.providers ?? []).filter((p) => p.script && p.category === "marketing").map((p) => p.script)
272
+ },
259
273
  yt: !!config.youtube,
274
+ banner: needsBanner(config),
275
+ policy: policyOf(config, locale),
260
276
  del: { analytics: del("analytics"), marketing: del("marketing") },
261
- t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen }
277
+ t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen, more: t.more }
262
278
  };
263
279
  }
264
280
 
@@ -304,12 +320,16 @@ var css = [
304
320
  ":where(.keksi-cat table){width:100%;border-collapse:collapse;font-size:.8125rem;margin-top:.5rem}",
305
321
  ":where(.keksi-cat) :where(th,td){padding:.3rem .75rem .3rem 0;border-bottom:1px solid var(--_line);text-align:left;vertical-align:top}",
306
322
  ":where(.keksi-cat) :where(th){font-weight:600}",
307
- ":where(keksi-youtube){display:block;aspect-ratio:16/9;background:#111;color:#fff}",
323
+ ":where(keksi-youtube){position:relative;display:block;aspect-ratio:16/9;overflow:hidden;border-radius:12px;background:#0f0f0f;color:#fff;font-family:Roboto,Arial,sans-serif}",
308
324
  ":where(keksi-youtube iframe){display:block;width:100%;height:100%;border:0}",
309
- ":where(.keksi-yt){display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;height:100%;padding:1rem;box-sizing:border-box;text-align:center}",
310
- ":where(.keksi-yt) :where(p){max-width:32rem;margin:0}",
311
- ":where(.keksi-yt) :where(a){color:inherit}",
312
- ":where(.keksi-yt button){font:inherit;font-weight:600;padding:.6rem 1.25rem;border:0;border-radius:6px;background:#fff;color:#111;cursor:pointer}"
325
+ ":where(keksi-youtube[label]:has(.keksi-yt))::before{content:attr(label);position:absolute;inset:0 0 auto;z-index:1;padding:1rem 1.25rem 2.5rem;font-size:1.125rem;font-weight:500;background:linear-gradient(#000b,#0000);pointer-events:none}",
326
+ ":where(.keksi-yt){position:relative;display:grid;grid-template-rows:1fr auto auto;gap:.25rem;height:100%;padding:0 1.25rem 1rem;box-sizing:border-box;font-size:.875rem;line-height:1.45;background:linear-gradient(#0000 55%,#000c),center/cover var(--keksi-poster,none)}",
327
+ ":where(.keksi-yt) :where(p){margin:0}",
328
+ ":where(.keksi-yt) :where(p:first-of-type){grid-row:2}",
329
+ ":where(.keksi-yt) :where(p:last-of-type){grid-row:3;color:#fffc}",
330
+ ":where(.keksi-yt) :where(a){color:inherit;text-decoration:underline;text-underline-offset:2px}",
331
+ `:where(.keksi-yt button){position:absolute;inset:0;width:68px;height:48px;margin:auto;padding:0;border:0;font-size:0;color:transparent;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 68 48'%3E%3Cpath d='M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z' fill='%23f00'/%3E%3Cpath d='M45 24 27 14v20' fill='%23fff'/%3E%3C/svg%3E") center/contain no-repeat;cursor:pointer;transition:transform .15s}`,
332
+ ":where(.keksi-yt button:hover){transform:scale(1.08)}"
313
333
  ].join("");
314
334
 
315
335
  // src/render.ts
@@ -319,12 +339,12 @@ function renderKeksi(config, { lang = "fi", nonce } = {}) {
319
339
  if (!cats.length) return "";
320
340
  const t = textsFor(config, lang);
321
341
  const active = activeProviders(config);
322
- const policy = typeof config.policyUrl === "string" ? config.policyUrl : config.policyUrl?.[lang];
342
+ const policy = policyOf(config, lang);
323
343
  const n = nonce ? ` nonce="${e(nonce)}"` : "";
324
344
  const btn = (action, label, type = "button") => `<button type="${type}" class="keksi-btn" data-keksi="${action}">${e(label)}</button>`;
325
345
  const uses = cats.map((c) => `<li><strong>${e(t[c])}:</strong> ${e(active.filter((p) => p.category === c).map((p) => p.name).join(", "))}</li>`).join("");
326
346
  const json = JSON.stringify(runtimeData(config, lang)).replace(/</g, "\\u003c");
327
- return `<style${n}>${css}</style><section id="keksi-banner" class="keksi" aria-labelledby="keksi-title" hidden><div class="keksi-main"><div class="keksi-head"><h2 id="keksi-title">${e(t.title)}</h2></div><div class="keksi-body"><p>${e(t.intro)}</p><ul>${uses}</ul><p>${e(t.change)}${policy ? ` <a href="${e(policy)}">${e(t.more)}</a>` : ""}</p></div><div class="keksi-actions"><button type="button" class="keksi-text" data-keksi-open>${e(t.settings)}</button>${btn("reject", t.reject)}${btn("accept", t.accept)}</div></div><form id="keksi-settings" class="keksi-settings" aria-labelledby="keksi-settings-title" hidden><div class="keksi-head"><h2 id="keksi-settings-title">${e(t.settingsTitle)}</h2><button type="button" class="keksi-close" data-keksi="close" aria-label="${e(t.close)}">\xD7</button></div><div class="keksi-body">${sections(config, lang, 3, true)}</div><div class="keksi-actions">${btn("save", t.save)}${btn("reject", t.reject)}${btn("accept", t.accept)}</div></form></section><script type="application/json" id="keksi-data">${json}</script><script${n}>${'"use strict";(()=>{var M=(o,s)=>new RegExp(`^${o.split("*").map(i=>i.replace(/[.+?^${}()|[\\]\\\\]/g,"\\\\$&")).join(".*")}$`).test(s);var $=["analytics","marketing"],C={analytics:!1,marketing:!1};function p(...o){return arguments}function j(o,s=window){if(typeof s.keksi?.sync=="function")return s.keksi.sync(),s.keksi;let i=s.document,f=e=>i.getElementById(e),u=q(),_=!1,A=!1,S=!1,v=new Set,L=p("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});if(o.ga.length||o.ads.length){let e=s.dataLayer??(s.dataLayer=[]);s.gtag=function(){e.push(arguments)},e.push(L)}if(o.meta.length){let e=function(){e.callMethod?e.callMethod.apply(e,[...arguments]):e.queue.push(arguments)};Object.assign(e,{push:e,loaded:!0,version:"2.0",queue:[]}),s.fbq=s._fbq=e}if(o.ph){let e=Object.assign([],{_i:[],people:[]});for(let n of"capture identify alias reset register register_once unregister group setPersonProperties getFeatureFlag isFeatureEnabled onFeatureFlags reloadFeatureFlags get_distinct_id startSessionRecording stopSessionRecording captureException on".split(" "))e[n]=(...t)=>e.push([n,...t]);s.posthog=e}function q(){let e=i.cookie.split("; ").find(r=>r.startsWith(`${o.name}=`))?.slice(o.name.length+1),[n,,t,a]=e?.split(".")??[];return n!==o.v||!t||!a?null:{id:t,c:{analytics:a.includes("a"),marketing:a.includes("m")}}}function F(e){let n=u?.id??Array.from(s.crypto.getRandomValues(new Uint8Array(8)),a=>a.toString(16).padStart(2,"0")).join(""),t=(e.analytics?"a":"")+(e.marketing?"m":"")||"-";return i.cookie=`${o.name}=${o.v}.${Date.now().toString(36)}.${n}.${t}; Path=/; Max-Age=${o.days*86400}; SameSite=Lax`+(o.domain?`; Domain=${o.domain}`:"")+(s.location.protocol==="https:"?"; Secure":""),{id:n,c:e}}function E(e){let n=i.createElement("script");return n.async=!0,n.src=e,i.head.append(n),n}function w(e){let n=[...e.analytics?o.ga:[],...e.marketing?o.ads:[]].filter(t=>!v.has(t));if(n.length){let t=e.marketing?"granted":"denied",a=[p("consent","update",{analytics_storage:e.analytics?"granted":"denied",ad_storage:t,ad_user_data:t,ad_personalization:t})];v.size||a.push(p("js",new Date));for(let r of n)a.push(p("config",r)),v.add(r);if(_)for(let r of a)s.dataLayer.push(r);else{let r=s.dataLayer;r.splice(r.indexOf(L)+1,0,...a),E(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(n[0])}`),_=!0}}if(e.marketing&&o.meta.length&&!A&&(A=!0,s.fbq.queue.unshift(...o.meta.map(t=>p("init",t)),p("track","PageView")),E("https://connect.facebook.net/en_US/fbevents.js")),e.analytics&&o.ph&&!S&&(S=!0,s.posthog._i.push([o.ph.key,{api_host:o.ph.host,defaults:"2026-08-30"},"posthog"]),E(`${o.ph.host.replace(".i.posthog.com","-assets.i.posthog.com")}/static/array.js`).crossOrigin="anonymous"),e.marketing)for(let t of i.querySelectorAll("keksi-youtube"))t.show(!0)}function H(e){let n=e.flatMap(c=>o.del[c]),t=c=>n.some(l=>M(l,c)),a=s.location.hostname.split("."),r=["",...a.map((c,l)=>`; Domain=${a.slice(l).join(".")}`)];for(let c of i.cookie.split("; ")){let l=c.split("=")[0];if(t(l))for(let b of r)i.cookie=`${l}=; Max-Age=0; Path=/${b}`}try{for(let c of[s.localStorage,s.sessionStorage])for(let l of Object.keys(c))t(l)&&c.removeItem(l)}catch{}}function g(e){let n=u?.c;u=F(e),y(),i.dispatchEvent(new s.CustomEvent("keksi:change",{detail:{...e}})),H($.filter(t=>!e[t])),$.some(t=>n?.[t]&&!e[t])?s.location.reload():w(e)}function h(e=!1,n=!u||e){let t=f("keksi-banner"),a=f("keksi-settings");!t||!a||(t.hidden=!n,a.hidden=!e,t.firstElementChild.hidden=e)}function y(){h()}function x(){let e=f("keksi-settings");for(let n of $){let t=e?.querySelector(`[name=${n}]`);t&&(t.checked=!!u?.c[n])}h(!0),e?.querySelector(".keksi-close")?.focus()}i.addEventListener("click",e=>{let n=e.target.closest?.("[data-keksi],[data-keksi-open]");if(!n)return;if(n.hasAttribute("data-keksi-open"))return e.preventDefault(),x();let t=n.getAttribute("data-keksi");if(t==="close")h();else if(t==="accept")g({analytics:!0,marketing:!0});else if(t==="reject")g(C);else if(t==="save"){let a=r=>!!f("keksi-settings")?.querySelector(`[name=${r}]`)?.checked;g({analytics:a("analytics"),marketing:a("marketing")})}}),i.addEventListener("keydown",e=>{e.key==="Escape"&&f("keksi-settings")?.hidden===!1&&h()}),i.addEventListener("astro:after-swap",y);class I extends s.HTMLElement{connectedCallback(){this.show(!!u?.c.marketing)}show(n,t=!1){let a=encodeURIComponent(this.getAttribute("video")??""),r=this.getAttribute("label");if(n){if(this.querySelector("iframe"))return;let d=i.createElement("iframe");d.src=`https://www.youtube-nocookie.com/embed/${a}${t?"?autoplay=1":""}`,d.title=r??"YouTube",d.allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",d.allowFullscreen=!0,d.referrerPolicy="strict-origin-when-cross-origin",this.replaceChildren(d),t&&d.focus();return}let c=i.createElement("div");c.className="keksi-yt";let l=this.getAttribute("poster");l&&(c.style.background=`linear-gradient(#000a,#000a),center/cover url(${JSON.stringify(l)})`);let b=i.createElement("p");b.textContent=o.t.ytText;let m=i.createElement("button");m.type="button",m.textContent=o.t.ytButton,r&&m.setAttribute("aria-label",`${o.t.ytButton}: ${r}`),m.onclick=()=>this.show(!0,!0);let k=i.createElement("a");k.href=`https://www.youtube.com/watch?v=${a}`,k.target="_blank",k.rel="noopener",k.textContent=o.t.ytOpen,c.append(b,m,k),this.replaceChildren(c)}}o.yt&&!s.customElements.get("keksi-youtube")&&s.customElements.define("keksi-youtube",I);let T={get consent(){return u?{...u.c}:null},open:x,save:e=>g({...u?.c??C,...e}),acceptAll:()=>g({analytics:!0,marketing:!0}),rejectAll:()=>g(C),sync:y};return s.keksi=T,y(),u&&w(u.c),T}j(JSON.parse(document.getElementById("keksi-data").textContent));})();'}</script>`;
347
+ return `<style${n}>${css}</style><section id="keksi-banner" class="keksi" aria-labelledby="keksi-title" hidden><div class="keksi-main"><div class="keksi-head"><h2 id="keksi-title">${e(t.title)}</h2></div><div class="keksi-body"><p>${e(t.intro)}</p><ul>${uses}</ul><p>${e(t.change)}${policy ? ` <a href="${e(policy)}">${e(t.more)}</a>` : ""}</p></div><div class="keksi-actions"><button type="button" class="keksi-text" data-keksi-open>${e(t.settings)}</button>${btn("reject", t.reject)}${btn("accept", t.accept)}</div></div><form id="keksi-settings" class="keksi-settings" aria-labelledby="keksi-settings-title" hidden><div class="keksi-head"><h2 id="keksi-settings-title">${e(t.settingsTitle)}</h2><button type="button" class="keksi-close" data-keksi="close" aria-label="${e(t.close)}">\xD7</button></div><div class="keksi-body">${sections(config, lang, 3, true)}</div><div class="keksi-actions">${btn("save", t.save)}${btn("reject", t.reject)}${btn("accept", t.accept)}</div></form></section><script type="application/json" id="keksi-data">${json}</script><script${n}>${'"use strict";(()=>{var H=(n,s)=>new RegExp(`^${n.split("*").map(i=>i.replace(/[.+?^${}()|[\\]\\\\]/g,"\\\\$&")).join(".*")}$`).test(s);var E=["analytics","marketing"],$={analytics:!1,marketing:!1};function f(...n){return arguments}function I(n,s=window){if(typeof s.keksi?.sync=="function")return s.keksi.sync(),s.keksi;let i=s.document,g=e=>i.getElementById(e),c=O(),A=!1,w=!1,L=!1,S=new Set,x=new Set,T=f("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});if(n.ga.length||n.ads.length){let e=s.dataLayer??(s.dataLayer=[]);s.gtag=function(){e.push(arguments)},e.push(T)}if(n.meta.length){let e=function(){e.callMethod?e.callMethod.apply(e,[...arguments]):e.queue.push(arguments)};Object.assign(e,{push:e,loaded:!0,version:"2.0",queue:[]}),s.fbq=s._fbq=e}if(n.ph){let e=Object.assign([],{_i:[],people:[]});for(let o of"capture identify alias reset register register_once unregister group setPersonProperties getFeatureFlag isFeatureEnabled onFeatureFlags reloadFeatureFlags get_distinct_id startSessionRecording stopSessionRecording captureException on".split(" "))e[o]=(...t)=>e.push([o,...t]);s.posthog=e}function O(){let e=i.cookie.split("; ").find(l=>l.startsWith(`${n.name}=`))?.slice(n.name.length+1),[o,,t,a]=e?.split(".")??[];if(o!==n.v||!t||!a)return null;let r={analytics:a.includes("a"),marketing:a.includes("m")};return{id:t,c:r,yt:r.marketing||a.includes("y"),done:!a.includes("u")}}function M(e,o=e.marketing,t=!0){let a=c?.id??Array.from(s.crypto.getRandomValues(new Uint8Array(8)),l=>l.toString(16).padStart(2,"0")).join(""),r=(t?"":"u")+(e.analytics?"a":"")+(e.marketing?"m":"")+(o&&!e.marketing?"y":"")||"-";return i.cookie=`${n.name}=${n.v}.${Date.now().toString(36)}.${a}.${r}; Path=/; Max-Age=${n.days*86400}; SameSite=Lax`+(n.domain?`; Domain=${n.domain}`:"")+(s.location.protocol==="https:"?"; Secure":""),{id:a,c:e,yt:o,done:t}}function h(e){let o=i.createElement("script");return o.async=!0,o.src=e,i.head.append(o),o}function C(e){let o=[...e.analytics?n.ga:[],...e.marketing?n.ads:[]].filter(t=>!S.has(t));if(o.length){let t=e.marketing?"granted":"denied",a=[f("consent","update",{analytics_storage:e.analytics?"granted":"denied",ad_storage:t,ad_user_data:t,ad_personalization:t})];S.size||a.push(f("js",new Date));for(let r of o)a.push(f("config",r)),S.add(r);if(A)for(let r of a)s.dataLayer.push(r);else{let r=s.dataLayer;r.splice(r.indexOf(T)+1,0,...a),h(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(o[0])}`),A=!0}}e.marketing&&n.meta.length&&!w&&(w=!0,s.fbq.queue.unshift(...n.meta.map(t=>f("init",t)),f("track","PageView")),h("https://connect.facebook.net/en_US/fbevents.js")),e.analytics&&n.ph&&!L&&(L=!0,s.posthog._i.push([n.ph.key,{api_host:n.ph.host,defaults:"2026-08-30"},"posthog"]),h(`${n.ph.host.replace(".i.posthog.com","-assets.i.posthog.com")}/static/array.js`).crossOrigin="anonymous");for(let t of E.flatMap(a=>e[a]?n.scripts[a]:[]))x.has(t)||(x.add(t),h(t));if(c?.yt)for(let t of i.querySelectorAll("keksi-youtube"))t.show(!0)}function R(){c?.yt||(c=M(c?.c??$,!0,c?.done??!1)),C(c.c)}function D(e){let o=e.flatMap(l=>n.del[l]),t=l=>o.some(u=>H(u,l)),a=s.location.hostname.split("."),r=["",...a.map((l,u)=>`; Domain=${a.slice(u).join(".")}`)];for(let l of i.cookie.split("; ")){let u=l.split("=")[0];if(t(u))for(let v of r)i.cookie=`${u}=; Max-Age=0; Path=/${v}`}try{for(let l of[s.localStorage,s.sessionStorage])for(let u of Object.keys(l))t(u)&&l.removeItem(u)}catch{}}function p(e){let o=c;c=M(e),b(),i.dispatchEvent(new s.CustomEvent("keksi:change",{detail:{...e}})),D(E.filter(t=>!e[t])),E.some(t=>o?.c[t]&&!e[t])||o?.yt&&!c.yt?s.location.reload():C(e)}function y(e=!1,o=n.banner&&!c?.done||e){let t=g("keksi-banner"),a=g("keksi-settings");!t||!a||(t.hidden=!o,a.hidden=!e,t.firstElementChild.hidden=e)}function b(){y()}function j(){let e=g("keksi-settings");for(let o of E){let t=e?.querySelector(`[name=${o}]`);t&&(t.checked=!!c?.c[o])}y(!0),e?.querySelector(".keksi-close")?.focus()}i.addEventListener("click",e=>{let o=e.target.closest?.("[data-keksi],[data-keksi-open]");if(!o)return;if(o.hasAttribute("data-keksi-open"))return e.preventDefault(),j();let t=o.getAttribute("data-keksi");if(t==="close")y();else if(t==="accept")p({analytics:!0,marketing:!0});else if(t==="reject")p($);else if(t==="save"){let a=r=>!!g("keksi-settings")?.querySelector(`[name=${r}]`)?.checked;p({analytics:a("analytics"),marketing:a("marketing")})}}),i.addEventListener("keydown",e=>{e.key==="Escape"&&g("keksi-settings")?.hidden===!1&&y()}),i.addEventListener("astro:after-swap",b);class N extends s.HTMLElement{connectedCallback(){this.show(!!c?.yt)}show(o,t=!1){let a=encodeURIComponent(this.getAttribute("video")??""),r=this.getAttribute("label");if(o){if(this.querySelector("iframe"))return;let d=i.createElement("iframe");d.src=`https://www.youtube-nocookie.com/embed/${a}${t?"?autoplay=1":""}`,d.title=r??"YouTube",d.allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",d.allowFullscreen=!0,d.referrerPolicy="strict-origin-when-cross-origin",this.replaceChildren(d),t&&d.focus();return}let l=i.createElement("div");l.className="keksi-yt";let u=this.getAttribute("poster");u&&l.style.setProperty("--keksi-poster",`url(${JSON.stringify(u)})`);let v=i.createElement("p");v.textContent=n.t.ytText;let m=i.createElement("button");m.type="button",m.textContent=n.t.ytButton,r&&m.setAttribute("aria-label",`${n.t.ytButton}: ${r}`),m.onclick=()=>{this.show(!0,!0),R()};let _=i.createElement("p"),F=(d,P,B=!1)=>{let k=i.createElement("a");k.href=d,k.textContent=P,B&&(k.target="_blank",k.rel="noopener"),_.append(_.childNodes.length?" \\xB7 ":"",k)};F(`https://www.youtube.com/watch?v=${a}`,n.t.ytOpen,!0),n.policy&&F(n.policy,n.t.more),l.append(v,m,_),this.replaceChildren(l)}}n.yt&&!s.customElements.get("keksi-youtube")&&s.customElements.define("keksi-youtube",N);let q={get consent(){return c?.done?{...c.c}:null},open:j,save:e=>p({...c?.c??$,...e}),acceptAll:()=>p({analytics:!0,marketing:!0}),rejectAll:()=>p($),sync:b};return s.keksi=q,b(),c&&C(c.c),q}I(JSON.parse(document.getElementById("keksi-data").textContent));})();'}</script>`;
328
348
  }
329
349
  function cookieTable(config, { lang = "fi", headingLevel = 2 } = {}) {
330
350
  return `<div class="keksi-policy">${sections(config, lang, headingLevel, false)}</div>`;
@@ -5,7 +5,7 @@ export interface TrackingOptions {
5
5
  allow?: string[];
6
6
  }
7
7
  /**
8
- * Records requests to Google, Meta and YouTube from now on. `check()` throws when any were made or when the page
8
+ * Records requests to the built-in and configured providers from now on. `check()` throws when any were made or when the page
9
9
  * stored anything besides the consent cookie and `allow`.
10
10
  */
11
11
  export declare function watchTracking(page: Page, config: KeksiConfig, { allow }?: TrackingOptions): {
@@ -33,9 +33,9 @@ var fi = {
33
33
  mo: "kuukausi|kuukautta",
34
34
  d: "p\xE4iv\xE4|p\xE4iv\xE4\xE4",
35
35
  min: "minuutti|minuuttia",
36
- ytText: "Video ladataan YouTubesta. YouTube (Google) asettaa ev\xE4steit\xE4 ja voi siirt\xE4\xE4 tietoja Yhdysvaltoihin.",
37
- ytButton: "N\xE4yt\xE4 video",
38
- ytOpen: "Avaa YouTubessa"
36
+ ytText: "Toistamalla videon hyv\xE4ksyt YouTuben ev\xE4steet.",
37
+ ytButton: "Toista video",
38
+ ytOpen: "Katso YouTubessa"
39
39
  };
40
40
  var sv = {
41
41
  title: "Kakor",
@@ -71,9 +71,9 @@ var sv = {
71
71
  mo: "m\xE5nad|m\xE5nader",
72
72
  d: "dag|dagar",
73
73
  min: "minut|minuter",
74
- ytText: "Videon laddas fr\xE5n YouTube. YouTube (Google) s\xE4tter kakor och kan \xF6verf\xF6ra uppgifter till USA.",
75
- ytButton: "Visa video",
76
- ytOpen: "\xD6ppna p\xE5 YouTube"
74
+ ytText: "Genom att spela upp videon godk\xE4nner du kakor fr\xE5n YouTube.",
75
+ ytButton: "Spela upp video",
76
+ ytOpen: "Titta p\xE5 YouTube"
77
77
  };
78
78
  var en = {
79
79
  title: "Cookies",
@@ -109,9 +109,9 @@ var en = {
109
109
  mo: "month|months",
110
110
  d: "day|days",
111
111
  min: "minute|minutes",
112
- ytText: "The video loads from YouTube. YouTube (Google) sets cookies and may transfer data to the United States.",
113
- ytButton: "Show video",
114
- ytOpen: "Open on YouTube"
112
+ ytText: "By playing the video, you accept cookies from YouTube.",
113
+ ytButton: "Play video",
114
+ ytOpen: "Watch on YouTube"
115
115
  };
116
116
  var texts = { fi, sv, en };
117
117
 
@@ -227,7 +227,17 @@ var posthogHost = (config) => (config.posthog?.host ?? "https://eu.i.posthog.com
227
227
  function activeProviders(config) {
228
228
  return providers.filter(
229
229
  (p) => p.id === "youtube" ? config.youtube : p.id === "posthog" ? config.posthog?.key : list(config[p.id]).length
230
- ).map((p) => p.id === "posthog" && /\/\/us\./.test(posthogHost(config)) ? { ...p, transfer: "us" } : p);
230
+ ).map((p) => p.id === "posthog" && /\/\/us\./.test(posthogHost(config)) ? { ...p, transfer: "us" } : p).concat((config.providers ?? []).map(custom));
231
+ }
232
+ function custom({ purpose, hosts = [], script: _, ...p }) {
233
+ const text = (l) => typeof purpose === "string" ? purpose : purpose[l] ?? Object.values(purpose).find(Boolean) ?? "";
234
+ return { ...p, id: `custom:${p.name}`, purpose: { fi: text("fi"), sv: text("sv"), en: text("en") }, hosts };
235
+ }
236
+ function needsBanner(config) {
237
+ return activeProviders(config).some((p) => p.id !== "youtube");
238
+ }
239
+ function policyOf(config, locale) {
240
+ return typeof config.policyUrl === "string" ? config.policyUrl : config.policyUrl?.[locale];
231
241
  }
232
242
  function textsFor(config, locale) {
233
243
  return { ...texts[locale], ...config.texts?.[locale] };
@@ -250,15 +260,21 @@ function runtimeData(config, locale) {
250
260
  ads: list(config.googleAds),
251
261
  meta: list(config.meta),
252
262
  ph: config.posthog?.key ? { key: config.posthog.key, host: posthogHost(config) } : void 0,
263
+ scripts: {
264
+ analytics: (config.providers ?? []).filter((p) => p.script && p.category === "analytics").map((p) => p.script),
265
+ marketing: (config.providers ?? []).filter((p) => p.script && p.category === "marketing").map((p) => p.script)
266
+ },
253
267
  yt: !!config.youtube,
268
+ banner: needsBanner(config),
269
+ policy: policyOf(config, locale),
254
270
  del: { analytics: del("analytics"), marketing: del("marketing") },
255
- t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen }
271
+ t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen, more: t.more }
256
272
  };
257
273
  }
258
274
 
259
275
  // src/playwright.ts
260
- var trackerHosts = [...new Set(providers.flatMap((p) => p.hosts))];
261
276
  function watchTracking(page, config, { allow = [] } = {}) {
277
+ const trackerHosts = [...providers, ...config.providers ?? []].flatMap((p) => p.hosts ?? []);
262
278
  const requests = [];
263
279
  page.on("request", (r) => {
264
280
  const host = new URL(r.url()).hostname;
@@ -1,2 +1,4 @@
1
- import type { Provider } from './types.js';
2
- export declare const providers: Provider[];
1
+ import type { Provider, ProviderId } from './types.js';
2
+ export declare const providers: (Provider & {
3
+ id: ProviderId;
4
+ })[];
package/dist/sveltekit.js CHANGED
@@ -33,9 +33,9 @@ var fi = {
33
33
  mo: "kuukausi|kuukautta",
34
34
  d: "p\xE4iv\xE4|p\xE4iv\xE4\xE4",
35
35
  min: "minuutti|minuuttia",
36
- ytText: "Video ladataan YouTubesta. YouTube (Google) asettaa ev\xE4steit\xE4 ja voi siirt\xE4\xE4 tietoja Yhdysvaltoihin.",
37
- ytButton: "N\xE4yt\xE4 video",
38
- ytOpen: "Avaa YouTubessa"
36
+ ytText: "Toistamalla videon hyv\xE4ksyt YouTuben ev\xE4steet.",
37
+ ytButton: "Toista video",
38
+ ytOpen: "Katso YouTubessa"
39
39
  };
40
40
  var sv = {
41
41
  title: "Kakor",
@@ -71,9 +71,9 @@ var sv = {
71
71
  mo: "m\xE5nad|m\xE5nader",
72
72
  d: "dag|dagar",
73
73
  min: "minut|minuter",
74
- ytText: "Videon laddas fr\xE5n YouTube. YouTube (Google) s\xE4tter kakor och kan \xF6verf\xF6ra uppgifter till USA.",
75
- ytButton: "Visa video",
76
- ytOpen: "\xD6ppna p\xE5 YouTube"
74
+ ytText: "Genom att spela upp videon godk\xE4nner du kakor fr\xE5n YouTube.",
75
+ ytButton: "Spela upp video",
76
+ ytOpen: "Titta p\xE5 YouTube"
77
77
  };
78
78
  var en = {
79
79
  title: "Cookies",
@@ -109,9 +109,9 @@ var en = {
109
109
  mo: "month|months",
110
110
  d: "day|days",
111
111
  min: "minute|minutes",
112
- ytText: "The video loads from YouTube. YouTube (Google) sets cookies and may transfer data to the United States.",
113
- ytButton: "Show video",
114
- ytOpen: "Open on YouTube"
112
+ ytText: "By playing the video, you accept cookies from YouTube.",
113
+ ytButton: "Play video",
114
+ ytOpen: "Watch on YouTube"
115
115
  };
116
116
  var texts = { fi, sv, en };
117
117
 
@@ -227,11 +227,21 @@ var posthogHost = (config) => (config.posthog?.host ?? "https://eu.i.posthog.com
227
227
  function activeProviders(config) {
228
228
  return providers.filter(
229
229
  (p) => p.id === "youtube" ? config.youtube : p.id === "posthog" ? config.posthog?.key : list(config[p.id]).length
230
- ).map((p) => p.id === "posthog" && /\/\/us\./.test(posthogHost(config)) ? { ...p, transfer: "us" } : p);
230
+ ).map((p) => p.id === "posthog" && /\/\/us\./.test(posthogHost(config)) ? { ...p, transfer: "us" } : p).concat((config.providers ?? []).map(custom));
231
+ }
232
+ function custom({ purpose, hosts = [], script: _, ...p }) {
233
+ const text = (l) => typeof purpose === "string" ? purpose : purpose[l] ?? Object.values(purpose).find(Boolean) ?? "";
234
+ return { ...p, id: `custom:${p.name}`, purpose: { fi: text("fi"), sv: text("sv"), en: text("en") }, hosts };
231
235
  }
232
236
  function categoriesOf(config) {
233
237
  return ["analytics", "marketing"].filter((c) => activeProviders(config).some((p) => p.category === c));
234
238
  }
239
+ function needsBanner(config) {
240
+ return activeProviders(config).some((p) => p.id !== "youtube");
241
+ }
242
+ function policyOf(config, locale) {
243
+ return typeof config.policyUrl === "string" ? config.policyUrl : config.policyUrl?.[locale];
244
+ }
235
245
  function textsFor(config, locale) {
236
246
  return { ...texts[locale], ...config.texts?.[locale] };
237
247
  }
@@ -253,9 +263,15 @@ function runtimeData(config, locale) {
253
263
  ads: list(config.googleAds),
254
264
  meta: list(config.meta),
255
265
  ph: config.posthog?.key ? { key: config.posthog.key, host: posthogHost(config) } : void 0,
266
+ scripts: {
267
+ analytics: (config.providers ?? []).filter((p) => p.script && p.category === "analytics").map((p) => p.script),
268
+ marketing: (config.providers ?? []).filter((p) => p.script && p.category === "marketing").map((p) => p.script)
269
+ },
256
270
  yt: !!config.youtube,
271
+ banner: needsBanner(config),
272
+ policy: policyOf(config, locale),
257
273
  del: { analytics: del("analytics"), marketing: del("marketing") },
258
- t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen }
274
+ t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen, more: t.more }
259
275
  };
260
276
  }
261
277
 
@@ -301,12 +317,16 @@ var css = [
301
317
  ":where(.keksi-cat table){width:100%;border-collapse:collapse;font-size:.8125rem;margin-top:.5rem}",
302
318
  ":where(.keksi-cat) :where(th,td){padding:.3rem .75rem .3rem 0;border-bottom:1px solid var(--_line);text-align:left;vertical-align:top}",
303
319
  ":where(.keksi-cat) :where(th){font-weight:600}",
304
- ":where(keksi-youtube){display:block;aspect-ratio:16/9;background:#111;color:#fff}",
320
+ ":where(keksi-youtube){position:relative;display:block;aspect-ratio:16/9;overflow:hidden;border-radius:12px;background:#0f0f0f;color:#fff;font-family:Roboto,Arial,sans-serif}",
305
321
  ":where(keksi-youtube iframe){display:block;width:100%;height:100%;border:0}",
306
- ":where(.keksi-yt){display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;height:100%;padding:1rem;box-sizing:border-box;text-align:center}",
307
- ":where(.keksi-yt) :where(p){max-width:32rem;margin:0}",
308
- ":where(.keksi-yt) :where(a){color:inherit}",
309
- ":where(.keksi-yt button){font:inherit;font-weight:600;padding:.6rem 1.25rem;border:0;border-radius:6px;background:#fff;color:#111;cursor:pointer}"
322
+ ":where(keksi-youtube[label]:has(.keksi-yt))::before{content:attr(label);position:absolute;inset:0 0 auto;z-index:1;padding:1rem 1.25rem 2.5rem;font-size:1.125rem;font-weight:500;background:linear-gradient(#000b,#0000);pointer-events:none}",
323
+ ":where(.keksi-yt){position:relative;display:grid;grid-template-rows:1fr auto auto;gap:.25rem;height:100%;padding:0 1.25rem 1rem;box-sizing:border-box;font-size:.875rem;line-height:1.45;background:linear-gradient(#0000 55%,#000c),center/cover var(--keksi-poster,none)}",
324
+ ":where(.keksi-yt) :where(p){margin:0}",
325
+ ":where(.keksi-yt) :where(p:first-of-type){grid-row:2}",
326
+ ":where(.keksi-yt) :where(p:last-of-type){grid-row:3;color:#fffc}",
327
+ ":where(.keksi-yt) :where(a){color:inherit;text-decoration:underline;text-underline-offset:2px}",
328
+ `:where(.keksi-yt button){position:absolute;inset:0;width:68px;height:48px;margin:auto;padding:0;border:0;font-size:0;color:transparent;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 68 48'%3E%3Cpath d='M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z' fill='%23f00'/%3E%3Cpath d='M45 24 27 14v20' fill='%23fff'/%3E%3C/svg%3E") center/contain no-repeat;cursor:pointer;transition:transform .15s}`,
329
+ ":where(.keksi-yt button:hover){transform:scale(1.08)}"
310
330
  ].join("");
311
331
 
312
332
  // src/render.ts
@@ -316,12 +336,12 @@ function renderKeksi(config, { lang = "fi", nonce } = {}) {
316
336
  if (!cats.length) return "";
317
337
  const t = textsFor(config, lang);
318
338
  const active = activeProviders(config);
319
- const policy = typeof config.policyUrl === "string" ? config.policyUrl : config.policyUrl?.[lang];
339
+ const policy = policyOf(config, lang);
320
340
  const n = nonce ? ` nonce="${e(nonce)}"` : "";
321
341
  const btn = (action, label, type = "button") => `<button type="${type}" class="keksi-btn" data-keksi="${action}">${e(label)}</button>`;
322
342
  const uses = cats.map((c) => `<li><strong>${e(t[c])}:</strong> ${e(active.filter((p) => p.category === c).map((p) => p.name).join(", "))}</li>`).join("");
323
343
  const json = JSON.stringify(runtimeData(config, lang)).replace(/</g, "\\u003c");
324
- return `<style${n}>${css}</style><section id="keksi-banner" class="keksi" aria-labelledby="keksi-title" hidden><div class="keksi-main"><div class="keksi-head"><h2 id="keksi-title">${e(t.title)}</h2></div><div class="keksi-body"><p>${e(t.intro)}</p><ul>${uses}</ul><p>${e(t.change)}${policy ? ` <a href="${e(policy)}">${e(t.more)}</a>` : ""}</p></div><div class="keksi-actions"><button type="button" class="keksi-text" data-keksi-open>${e(t.settings)}</button>${btn("reject", t.reject)}${btn("accept", t.accept)}</div></div><form id="keksi-settings" class="keksi-settings" aria-labelledby="keksi-settings-title" hidden><div class="keksi-head"><h2 id="keksi-settings-title">${e(t.settingsTitle)}</h2><button type="button" class="keksi-close" data-keksi="close" aria-label="${e(t.close)}">\xD7</button></div><div class="keksi-body">${sections(config, lang, 3, true)}</div><div class="keksi-actions">${btn("save", t.save)}${btn("reject", t.reject)}${btn("accept", t.accept)}</div></form></section><script type="application/json" id="keksi-data">${json}</script><script${n}>${'"use strict";(()=>{var M=(o,s)=>new RegExp(`^${o.split("*").map(i=>i.replace(/[.+?^${}()|[\\]\\\\]/g,"\\\\$&")).join(".*")}$`).test(s);var $=["analytics","marketing"],C={analytics:!1,marketing:!1};function p(...o){return arguments}function j(o,s=window){if(typeof s.keksi?.sync=="function")return s.keksi.sync(),s.keksi;let i=s.document,f=e=>i.getElementById(e),u=q(),_=!1,A=!1,S=!1,v=new Set,L=p("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});if(o.ga.length||o.ads.length){let e=s.dataLayer??(s.dataLayer=[]);s.gtag=function(){e.push(arguments)},e.push(L)}if(o.meta.length){let e=function(){e.callMethod?e.callMethod.apply(e,[...arguments]):e.queue.push(arguments)};Object.assign(e,{push:e,loaded:!0,version:"2.0",queue:[]}),s.fbq=s._fbq=e}if(o.ph){let e=Object.assign([],{_i:[],people:[]});for(let n of"capture identify alias reset register register_once unregister group setPersonProperties getFeatureFlag isFeatureEnabled onFeatureFlags reloadFeatureFlags get_distinct_id startSessionRecording stopSessionRecording captureException on".split(" "))e[n]=(...t)=>e.push([n,...t]);s.posthog=e}function q(){let e=i.cookie.split("; ").find(r=>r.startsWith(`${o.name}=`))?.slice(o.name.length+1),[n,,t,a]=e?.split(".")??[];return n!==o.v||!t||!a?null:{id:t,c:{analytics:a.includes("a"),marketing:a.includes("m")}}}function F(e){let n=u?.id??Array.from(s.crypto.getRandomValues(new Uint8Array(8)),a=>a.toString(16).padStart(2,"0")).join(""),t=(e.analytics?"a":"")+(e.marketing?"m":"")||"-";return i.cookie=`${o.name}=${o.v}.${Date.now().toString(36)}.${n}.${t}; Path=/; Max-Age=${o.days*86400}; SameSite=Lax`+(o.domain?`; Domain=${o.domain}`:"")+(s.location.protocol==="https:"?"; Secure":""),{id:n,c:e}}function E(e){let n=i.createElement("script");return n.async=!0,n.src=e,i.head.append(n),n}function w(e){let n=[...e.analytics?o.ga:[],...e.marketing?o.ads:[]].filter(t=>!v.has(t));if(n.length){let t=e.marketing?"granted":"denied",a=[p("consent","update",{analytics_storage:e.analytics?"granted":"denied",ad_storage:t,ad_user_data:t,ad_personalization:t})];v.size||a.push(p("js",new Date));for(let r of n)a.push(p("config",r)),v.add(r);if(_)for(let r of a)s.dataLayer.push(r);else{let r=s.dataLayer;r.splice(r.indexOf(L)+1,0,...a),E(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(n[0])}`),_=!0}}if(e.marketing&&o.meta.length&&!A&&(A=!0,s.fbq.queue.unshift(...o.meta.map(t=>p("init",t)),p("track","PageView")),E("https://connect.facebook.net/en_US/fbevents.js")),e.analytics&&o.ph&&!S&&(S=!0,s.posthog._i.push([o.ph.key,{api_host:o.ph.host,defaults:"2026-08-30"},"posthog"]),E(`${o.ph.host.replace(".i.posthog.com","-assets.i.posthog.com")}/static/array.js`).crossOrigin="anonymous"),e.marketing)for(let t of i.querySelectorAll("keksi-youtube"))t.show(!0)}function H(e){let n=e.flatMap(c=>o.del[c]),t=c=>n.some(l=>M(l,c)),a=s.location.hostname.split("."),r=["",...a.map((c,l)=>`; Domain=${a.slice(l).join(".")}`)];for(let c of i.cookie.split("; ")){let l=c.split("=")[0];if(t(l))for(let b of r)i.cookie=`${l}=; Max-Age=0; Path=/${b}`}try{for(let c of[s.localStorage,s.sessionStorage])for(let l of Object.keys(c))t(l)&&c.removeItem(l)}catch{}}function g(e){let n=u?.c;u=F(e),y(),i.dispatchEvent(new s.CustomEvent("keksi:change",{detail:{...e}})),H($.filter(t=>!e[t])),$.some(t=>n?.[t]&&!e[t])?s.location.reload():w(e)}function h(e=!1,n=!u||e){let t=f("keksi-banner"),a=f("keksi-settings");!t||!a||(t.hidden=!n,a.hidden=!e,t.firstElementChild.hidden=e)}function y(){h()}function x(){let e=f("keksi-settings");for(let n of $){let t=e?.querySelector(`[name=${n}]`);t&&(t.checked=!!u?.c[n])}h(!0),e?.querySelector(".keksi-close")?.focus()}i.addEventListener("click",e=>{let n=e.target.closest?.("[data-keksi],[data-keksi-open]");if(!n)return;if(n.hasAttribute("data-keksi-open"))return e.preventDefault(),x();let t=n.getAttribute("data-keksi");if(t==="close")h();else if(t==="accept")g({analytics:!0,marketing:!0});else if(t==="reject")g(C);else if(t==="save"){let a=r=>!!f("keksi-settings")?.querySelector(`[name=${r}]`)?.checked;g({analytics:a("analytics"),marketing:a("marketing")})}}),i.addEventListener("keydown",e=>{e.key==="Escape"&&f("keksi-settings")?.hidden===!1&&h()}),i.addEventListener("astro:after-swap",y);class I extends s.HTMLElement{connectedCallback(){this.show(!!u?.c.marketing)}show(n,t=!1){let a=encodeURIComponent(this.getAttribute("video")??""),r=this.getAttribute("label");if(n){if(this.querySelector("iframe"))return;let d=i.createElement("iframe");d.src=`https://www.youtube-nocookie.com/embed/${a}${t?"?autoplay=1":""}`,d.title=r??"YouTube",d.allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",d.allowFullscreen=!0,d.referrerPolicy="strict-origin-when-cross-origin",this.replaceChildren(d),t&&d.focus();return}let c=i.createElement("div");c.className="keksi-yt";let l=this.getAttribute("poster");l&&(c.style.background=`linear-gradient(#000a,#000a),center/cover url(${JSON.stringify(l)})`);let b=i.createElement("p");b.textContent=o.t.ytText;let m=i.createElement("button");m.type="button",m.textContent=o.t.ytButton,r&&m.setAttribute("aria-label",`${o.t.ytButton}: ${r}`),m.onclick=()=>this.show(!0,!0);let k=i.createElement("a");k.href=`https://www.youtube.com/watch?v=${a}`,k.target="_blank",k.rel="noopener",k.textContent=o.t.ytOpen,c.append(b,m,k),this.replaceChildren(c)}}o.yt&&!s.customElements.get("keksi-youtube")&&s.customElements.define("keksi-youtube",I);let T={get consent(){return u?{...u.c}:null},open:x,save:e=>g({...u?.c??C,...e}),acceptAll:()=>g({analytics:!0,marketing:!0}),rejectAll:()=>g(C),sync:y};return s.keksi=T,y(),u&&w(u.c),T}j(JSON.parse(document.getElementById("keksi-data").textContent));})();'}</script>`;
344
+ return `<style${n}>${css}</style><section id="keksi-banner" class="keksi" aria-labelledby="keksi-title" hidden><div class="keksi-main"><div class="keksi-head"><h2 id="keksi-title">${e(t.title)}</h2></div><div class="keksi-body"><p>${e(t.intro)}</p><ul>${uses}</ul><p>${e(t.change)}${policy ? ` <a href="${e(policy)}">${e(t.more)}</a>` : ""}</p></div><div class="keksi-actions"><button type="button" class="keksi-text" data-keksi-open>${e(t.settings)}</button>${btn("reject", t.reject)}${btn("accept", t.accept)}</div></div><form id="keksi-settings" class="keksi-settings" aria-labelledby="keksi-settings-title" hidden><div class="keksi-head"><h2 id="keksi-settings-title">${e(t.settingsTitle)}</h2><button type="button" class="keksi-close" data-keksi="close" aria-label="${e(t.close)}">\xD7</button></div><div class="keksi-body">${sections(config, lang, 3, true)}</div><div class="keksi-actions">${btn("save", t.save)}${btn("reject", t.reject)}${btn("accept", t.accept)}</div></form></section><script type="application/json" id="keksi-data">${json}</script><script${n}>${'"use strict";(()=>{var H=(n,s)=>new RegExp(`^${n.split("*").map(i=>i.replace(/[.+?^${}()|[\\]\\\\]/g,"\\\\$&")).join(".*")}$`).test(s);var E=["analytics","marketing"],$={analytics:!1,marketing:!1};function f(...n){return arguments}function I(n,s=window){if(typeof s.keksi?.sync=="function")return s.keksi.sync(),s.keksi;let i=s.document,g=e=>i.getElementById(e),c=O(),A=!1,w=!1,L=!1,S=new Set,x=new Set,T=f("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});if(n.ga.length||n.ads.length){let e=s.dataLayer??(s.dataLayer=[]);s.gtag=function(){e.push(arguments)},e.push(T)}if(n.meta.length){let e=function(){e.callMethod?e.callMethod.apply(e,[...arguments]):e.queue.push(arguments)};Object.assign(e,{push:e,loaded:!0,version:"2.0",queue:[]}),s.fbq=s._fbq=e}if(n.ph){let e=Object.assign([],{_i:[],people:[]});for(let o of"capture identify alias reset register register_once unregister group setPersonProperties getFeatureFlag isFeatureEnabled onFeatureFlags reloadFeatureFlags get_distinct_id startSessionRecording stopSessionRecording captureException on".split(" "))e[o]=(...t)=>e.push([o,...t]);s.posthog=e}function O(){let e=i.cookie.split("; ").find(l=>l.startsWith(`${n.name}=`))?.slice(n.name.length+1),[o,,t,a]=e?.split(".")??[];if(o!==n.v||!t||!a)return null;let r={analytics:a.includes("a"),marketing:a.includes("m")};return{id:t,c:r,yt:r.marketing||a.includes("y"),done:!a.includes("u")}}function M(e,o=e.marketing,t=!0){let a=c?.id??Array.from(s.crypto.getRandomValues(new Uint8Array(8)),l=>l.toString(16).padStart(2,"0")).join(""),r=(t?"":"u")+(e.analytics?"a":"")+(e.marketing?"m":"")+(o&&!e.marketing?"y":"")||"-";return i.cookie=`${n.name}=${n.v}.${Date.now().toString(36)}.${a}.${r}; Path=/; Max-Age=${n.days*86400}; SameSite=Lax`+(n.domain?`; Domain=${n.domain}`:"")+(s.location.protocol==="https:"?"; Secure":""),{id:a,c:e,yt:o,done:t}}function h(e){let o=i.createElement("script");return o.async=!0,o.src=e,i.head.append(o),o}function C(e){let o=[...e.analytics?n.ga:[],...e.marketing?n.ads:[]].filter(t=>!S.has(t));if(o.length){let t=e.marketing?"granted":"denied",a=[f("consent","update",{analytics_storage:e.analytics?"granted":"denied",ad_storage:t,ad_user_data:t,ad_personalization:t})];S.size||a.push(f("js",new Date));for(let r of o)a.push(f("config",r)),S.add(r);if(A)for(let r of a)s.dataLayer.push(r);else{let r=s.dataLayer;r.splice(r.indexOf(T)+1,0,...a),h(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(o[0])}`),A=!0}}e.marketing&&n.meta.length&&!w&&(w=!0,s.fbq.queue.unshift(...n.meta.map(t=>f("init",t)),f("track","PageView")),h("https://connect.facebook.net/en_US/fbevents.js")),e.analytics&&n.ph&&!L&&(L=!0,s.posthog._i.push([n.ph.key,{api_host:n.ph.host,defaults:"2026-08-30"},"posthog"]),h(`${n.ph.host.replace(".i.posthog.com","-assets.i.posthog.com")}/static/array.js`).crossOrigin="anonymous");for(let t of E.flatMap(a=>e[a]?n.scripts[a]:[]))x.has(t)||(x.add(t),h(t));if(c?.yt)for(let t of i.querySelectorAll("keksi-youtube"))t.show(!0)}function R(){c?.yt||(c=M(c?.c??$,!0,c?.done??!1)),C(c.c)}function D(e){let o=e.flatMap(l=>n.del[l]),t=l=>o.some(u=>H(u,l)),a=s.location.hostname.split("."),r=["",...a.map((l,u)=>`; Domain=${a.slice(u).join(".")}`)];for(let l of i.cookie.split("; ")){let u=l.split("=")[0];if(t(u))for(let v of r)i.cookie=`${u}=; Max-Age=0; Path=/${v}`}try{for(let l of[s.localStorage,s.sessionStorage])for(let u of Object.keys(l))t(u)&&l.removeItem(u)}catch{}}function p(e){let o=c;c=M(e),b(),i.dispatchEvent(new s.CustomEvent("keksi:change",{detail:{...e}})),D(E.filter(t=>!e[t])),E.some(t=>o?.c[t]&&!e[t])||o?.yt&&!c.yt?s.location.reload():C(e)}function y(e=!1,o=n.banner&&!c?.done||e){let t=g("keksi-banner"),a=g("keksi-settings");!t||!a||(t.hidden=!o,a.hidden=!e,t.firstElementChild.hidden=e)}function b(){y()}function j(){let e=g("keksi-settings");for(let o of E){let t=e?.querySelector(`[name=${o}]`);t&&(t.checked=!!c?.c[o])}y(!0),e?.querySelector(".keksi-close")?.focus()}i.addEventListener("click",e=>{let o=e.target.closest?.("[data-keksi],[data-keksi-open]");if(!o)return;if(o.hasAttribute("data-keksi-open"))return e.preventDefault(),j();let t=o.getAttribute("data-keksi");if(t==="close")y();else if(t==="accept")p({analytics:!0,marketing:!0});else if(t==="reject")p($);else if(t==="save"){let a=r=>!!g("keksi-settings")?.querySelector(`[name=${r}]`)?.checked;p({analytics:a("analytics"),marketing:a("marketing")})}}),i.addEventListener("keydown",e=>{e.key==="Escape"&&g("keksi-settings")?.hidden===!1&&y()}),i.addEventListener("astro:after-swap",b);class N extends s.HTMLElement{connectedCallback(){this.show(!!c?.yt)}show(o,t=!1){let a=encodeURIComponent(this.getAttribute("video")??""),r=this.getAttribute("label");if(o){if(this.querySelector("iframe"))return;let d=i.createElement("iframe");d.src=`https://www.youtube-nocookie.com/embed/${a}${t?"?autoplay=1":""}`,d.title=r??"YouTube",d.allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",d.allowFullscreen=!0,d.referrerPolicy="strict-origin-when-cross-origin",this.replaceChildren(d),t&&d.focus();return}let l=i.createElement("div");l.className="keksi-yt";let u=this.getAttribute("poster");u&&l.style.setProperty("--keksi-poster",`url(${JSON.stringify(u)})`);let v=i.createElement("p");v.textContent=n.t.ytText;let m=i.createElement("button");m.type="button",m.textContent=n.t.ytButton,r&&m.setAttribute("aria-label",`${n.t.ytButton}: ${r}`),m.onclick=()=>{this.show(!0,!0),R()};let _=i.createElement("p"),F=(d,P,B=!1)=>{let k=i.createElement("a");k.href=d,k.textContent=P,B&&(k.target="_blank",k.rel="noopener"),_.append(_.childNodes.length?" \\xB7 ":"",k)};F(`https://www.youtube.com/watch?v=${a}`,n.t.ytOpen,!0),n.policy&&F(n.policy,n.t.more),l.append(v,m,_),this.replaceChildren(l)}}n.yt&&!s.customElements.get("keksi-youtube")&&s.customElements.define("keksi-youtube",N);let q={get consent(){return c?.done?{...c.c}:null},open:j,save:e=>p({...c?.c??$,...e}),acceptAll:()=>p({analytics:!0,marketing:!0}),rejectAll:()=>p($),sync:b};return s.keksi=q,b(),c&&C(c.c),q}I(JSON.parse(document.getElementById("keksi-data").textContent));})();'}</script>`;
325
345
  }
326
346
  function sections(config, lang, h, dialog) {
327
347
  const t = textsFor(config, lang);
package/dist/types.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface KeksiConfig {
25
25
  domain?: string;
26
26
  days?: number;
27
27
  };
28
+ /** Providers keksi doesn't have built in, e.g. one client's chat widget. */
29
+ providers?: CustomProvider[];
28
30
  /** Cookies your site or framework sets without consent, usually from `keksi scan`. Listed under "Necessary". */
29
31
  necessary?: NecessaryCookie[];
30
32
  texts?: Partial<Record<Locale, Partial<Texts>>>;
@@ -38,6 +40,20 @@ export interface NecessaryCookie {
38
40
  /** `localStorage` or `sessionStorage`; omitted for cookies. */
39
41
  storage?: string;
40
42
  }
43
+ export interface CustomProvider {
44
+ name: string;
45
+ company: string;
46
+ privacyPolicy: string;
47
+ category: Category;
48
+ /** One text for all languages, or per language. */
49
+ purpose: string | Partial<Record<Locale, string>>;
50
+ cookies: CookieInfo[];
51
+ /** Hosts it contacts, for the Playwright check. */
52
+ hosts?: string[];
53
+ transfer?: 'dpf' | 'us';
54
+ /** Loaded after consent in `category`. Leave out to load it yourself on `keksi:change`. */
55
+ script?: string;
56
+ }
41
57
  /** `session`, `persistent`, or a number with unit: `2y`, `13mo`, `90d`, `15min`. */
42
58
  export type Duration = 'session' | 'persistent' | `${number}${'y' | 'mo' | 'd' | 'min'}`;
43
59
  export interface CookieInfo {
@@ -48,7 +64,8 @@ export interface CookieInfo {
48
64
  storage?: 'localStorage' | 'sessionStorage';
49
65
  }
50
66
  export interface Provider {
51
- id: ProviderId;
67
+ /** Custom providers get `custom:` and their name. */
68
+ id: ProviderId | `custom:${string}`;
52
69
  name: string;
53
70
  category: Category;
54
71
  company: string;
@@ -73,8 +90,13 @@ export interface RuntimeData {
73
90
  key: string;
74
91
  host: string;
75
92
  };
93
+ /** Custom provider scripts per category. */
94
+ scripts: Record<Category, string[]>;
76
95
  yt: boolean;
96
+ /** False with YouTube only: the settings exist but the banner doesn't show by itself. */
97
+ banner: boolean;
98
+ policy?: string;
77
99
  /** First-party cookie and storage names per category, `*` = wildcard. */
78
100
  del: Record<Category, string[]>;
79
- t: Pick<Texts, 'ytText' | 'ytButton' | 'ytOpen'>;
101
+ t: Pick<Texts, 'ytText' | 'ytButton' | 'ytOpen' | 'more'>;
80
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keksi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Lean cookie consent for Google and Meta trackers",
5
5
  "license": "MIT",
6
6
  "type": "module",