diff --git a/analytics/client/build.js b/analytics/client/build.js
new file mode 100644
index 0000000..a2cd8a9
--- /dev/null
+++ b/analytics/client/build.js
@@ -0,0 +1,26 @@
+// Erzeugt dist/w.js und dist/h.js: core.js + Einstieg als IIFE, ohne import/export (kein Build-Tool).
+// Aufruf: node client/build.js
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const DIR = dirname(fileURLToPath(import.meta.url));
+const read = (f) => readFileSync(join(DIR, f), 'utf8');
+
+function wrap(name, entry) {
+ const core = read('core.js').replace(/^export\s+/gm, '');
+ const body = read(entry).replace(/^import\s[^\n]*\n/gm, '');
+ return `/* WEBklar Analyse ${name} - generiert aus client/core.js + client/${entry} (node client/build.js), nicht von Hand aendern */\n(function () {\n'use strict';\n${core}\n${body}\n})();\n`;
+}
+
+export function buildAll() {
+ return { w: wrap('w.js', 'w.js'), h: wrap('h.js', 'h.js') };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const b = buildAll();
+ mkdirSync(join(DIR, 'dist'), { recursive: true });
+ writeFileSync(join(DIR, 'dist', 'w.js'), b.w);
+ writeFileSync(join(DIR, 'dist', 'h.js'), b.h);
+ console.log('dist/w.js', b.w.length, 'Bytes; dist/h.js', b.h.length, 'Bytes');
+}
diff --git a/analytics/client/core.js b/analytics/client/core.js
new file mode 100644
index 0000000..bf8d174
--- /dev/null
+++ b/analytics/client/core.js
@@ -0,0 +1,120 @@
+// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
+// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
+
+export const CONSENT_KEY = 'wk-consent';
+export const CONSENT_MAX_AGE = 183 * 86400000;
+
+export function vpClass(width) {
+ const w = Number(width);
+ if (w < 768) return 'm';
+ if (w < 1024) return 't';
+ return 'd';
+}
+
+export function cleanPath(href) {
+ let p = '/';
+ try {
+ p = new URL(String(href == null ? '' : href), 'http://x.invalid/').pathname || '/';
+ } catch (e) { /* bleibt '/' */ }
+ try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
+ if (p.charAt(0) !== '/') p = '/' + p;
+ return p.slice(0, 300);
+}
+
+const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
+
+export function refHost(referrer, ownHost) {
+ if (!referrer) return '';
+ try {
+ const u = new URL(String(referrer));
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ const h = u.hostname.toLowerCase();
+ if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
+ return h;
+ } catch (e) { return ''; }
+}
+
+// Erzeugt wirkende Namen (css-1a2b3c, ember12345, 9f8e7d6c5b4a ...) erkennen.
+function looksGenerated(s) {
+ if (/\d{4,}/.test(s)) return true;
+ const mixed = (t, min) => t.length >= min && /\d/.test(t) && /[a-z]/i.test(t);
+ if (mixed(s, 10) && !/[-_]/.test(s)) return true;
+ if (/^[a-f0-9]{8,}$/i.test(s) && /\d/.test(s)) return true;
+ return s.split(/[-_]/).some((p) => mixed(p, 6));
+}
+
+const safeName = (s) => String(s).replace(/[^\w-]/g, '');
+
+export function cssPath(el) {
+ const parts = [];
+ let n = el;
+ while (n && n.tagName && parts.length < 5) {
+ const tag = String(n.tagName).toLowerCase();
+ let seg = tag;
+ const id = n.id ? safeName(n.id) : '';
+ if (id && !looksGenerated(id)) {
+ parts.unshift(seg + '#' + id);
+ break;
+ }
+ const raw = n.getAttribute ? n.getAttribute('class') : '';
+ const cls = String(raw || '').split(/\s+/).map(safeName).filter((c) => c && !looksGenerated(c)).slice(0, 2);
+ if (cls.length) seg += '.' + cls.join('.');
+ parts.unshift(seg);
+ n = n.parentNode;
+ }
+ let out = parts.join(' > ');
+ while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
+ return out.slice(0, 200);
+}
+
+function parseHref(a) {
+ const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
+ return String(href).trim();
+}
+
+export function linkHost(a) {
+ try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
+}
+
+export function contactKind(a, ownHost) {
+ const href = parseHref(a);
+ if (!href) return null;
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
+ if (!scheme) return null; // relativ, Anker
+ const s = scheme[1].toLowerCase();
+ if (s === 'tel') return 'tel';
+ if (s === 'mailto') return 'mail';
+ if (s === 'whatsapp') return 'wa';
+ if (s !== 'http' && s !== 'https') return null;
+ const host = linkHost(a);
+ if (!host) return null;
+ if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
+ const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
+ return stripWww(host) === stripWww(own || '') ? null : 'out';
+}
+
+export function readConsent(storage, now) {
+ try {
+ const raw = storage.getItem(CONSENT_KEY);
+ if (!raw) return null;
+ const c = JSON.parse(raw);
+ if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
+ const age = now - c.t;
+ if (age < 0 || age >= CONSENT_MAX_AGE) return null;
+ return { v: 1, a: c.a, t: c.t };
+ } catch (e) { return null; }
+}
+
+export function contrastOk(hex) {
+ if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
+ const lin = (i) => {
+ const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
+ return 1.05 / (L + 0.05) >= 4.5;
+}
+
+export function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
diff --git a/analytics/client/dist/h.js b/analytics/client/dist/h.js
new file mode 100644
index 0000000..8a7fcf2
--- /dev/null
+++ b/analytics/client/dist/h.js
@@ -0,0 +1,224 @@
+/* WEBklar Analyse h.js - generiert aus client/core.js + client/h.js (node client/build.js), nicht von Hand aendern */
+(function () {
+'use strict';
+// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
+// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
+
+const CONSENT_KEY = 'wk-consent';
+const CONSENT_MAX_AGE = 183 * 86400000;
+
+function vpClass(width) {
+ const w = Number(width);
+ if (w < 768) return 'm';
+ if (w < 1024) return 't';
+ return 'd';
+}
+
+function cleanPath(href) {
+ let p = '/';
+ try {
+ p = new URL(String(href == null ? '' : href), 'http://x.invalid/').pathname || '/';
+ } catch (e) { /* bleibt '/' */ }
+ try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
+ if (p.charAt(0) !== '/') p = '/' + p;
+ return p.slice(0, 300);
+}
+
+const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
+
+function refHost(referrer, ownHost) {
+ if (!referrer) return '';
+ try {
+ const u = new URL(String(referrer));
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ const h = u.hostname.toLowerCase();
+ if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
+ return h;
+ } catch (e) { return ''; }
+}
+
+// Erzeugt wirkende Namen (css-1a2b3c, ember12345, 9f8e7d6c5b4a ...) erkennen.
+function looksGenerated(s) {
+ if (/\d{4,}/.test(s)) return true;
+ const mixed = (t, min) => t.length >= min && /\d/.test(t) && /[a-z]/i.test(t);
+ if (mixed(s, 10) && !/[-_]/.test(s)) return true;
+ if (/^[a-f0-9]{8,}$/i.test(s) && /\d/.test(s)) return true;
+ return s.split(/[-_]/).some((p) => mixed(p, 6));
+}
+
+const safeName = (s) => String(s).replace(/[^\w-]/g, '');
+
+function cssPath(el) {
+ const parts = [];
+ let n = el;
+ while (n && n.tagName && parts.length < 5) {
+ const tag = String(n.tagName).toLowerCase();
+ let seg = tag;
+ const id = n.id ? safeName(n.id) : '';
+ if (id && !looksGenerated(id)) {
+ parts.unshift(seg + '#' + id);
+ break;
+ }
+ const raw = n.getAttribute ? n.getAttribute('class') : '';
+ const cls = String(raw || '').split(/\s+/).map(safeName).filter((c) => c && !looksGenerated(c)).slice(0, 2);
+ if (cls.length) seg += '.' + cls.join('.');
+ parts.unshift(seg);
+ n = n.parentNode;
+ }
+ let out = parts.join(' > ');
+ while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
+ return out.slice(0, 200);
+}
+
+function parseHref(a) {
+ const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
+ return String(href).trim();
+}
+
+function linkHost(a) {
+ try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
+}
+
+function contactKind(a, ownHost) {
+ const href = parseHref(a);
+ if (!href) return null;
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
+ if (!scheme) return null; // relativ, Anker
+ const s = scheme[1].toLowerCase();
+ if (s === 'tel') return 'tel';
+ if (s === 'mailto') return 'mail';
+ if (s === 'whatsapp') return 'wa';
+ if (s !== 'http' && s !== 'https') return null;
+ const host = linkHost(a);
+ if (!host) return null;
+ if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
+ const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
+ return stripWww(host) === stripWww(own || '') ? null : 'out';
+}
+
+function readConsent(storage, now) {
+ try {
+ const raw = storage.getItem(CONSENT_KEY);
+ if (!raw) return null;
+ const c = JSON.parse(raw);
+ if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
+ const age = now - c.t;
+ if (age < 0 || age >= CONSENT_MAX_AGE) return null;
+ return { v: 1, a: c.a, t: c.t };
+ } catch (e) { return null; }
+}
+
+function contrastOk(hex) {
+ if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
+ const lin = (i) => {
+ const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
+ return 1.05 / (L + 0.05) >= 4.5;
+}
+
+function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
+
+function main() {
+ const me = document.currentScript;
+ const token = me && me.getAttribute('data-token');
+ const src = String((me && (me.src || me.getAttribute('src'))) || '').replace(/[?#].*$/, '');
+ const base = src.replace(/[^/]*$/, '');
+ if (!token || !base) return;
+
+ const de = document.documentElement;
+ const host = document.createElement('div');
+ host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
+ const root = host.attachShadow({ mode: 'open' });
+ const style = document.createElement('style');
+ style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
+ const bar = document.createElement('div');
+ bar.className = 'bar';
+ const msg = document.createElement('span');
+ msg.innerHTML = escapeHtml('Heatmap wird geladen ...');
+ const close = document.createElement('button');
+ close.setAttribute('type', 'button');
+ close.textContent = 'Schließen';
+ bar.appendChild(msg); bar.appendChild(close);
+ root.appendChild(style); root.appendChild(bar);
+ document.body.appendChild(host);
+
+ const canvas = document.createElement('canvas');
+ const cs = canvas.style;
+ cs.position = 'absolute'; cs.left = '0'; cs.top = '0'; cs.pointerEvents = 'none'; cs.zIndex = '2147483646';
+ document.body.appendChild(canvas);
+
+ let points = [];
+ let lastW = 0, lastH = 0, timer = 0;
+
+ // Farbverlauf: transparent -> blau -> gruen -> gelb -> rot
+ const ramp = (function () {
+ const c = document.createElement('canvas'); c.width = 256; c.height = 1;
+ const g = c.getContext('2d');
+ const lg = g.createLinearGradient(0, 0, 256, 0);
+ lg.addColorStop(0.25, 'rgb(0,0,255)'); lg.addColorStop(0.55, 'rgb(0,255,0)'); lg.addColorStop(0.85, 'rgb(255,255,0)'); lg.addColorStop(1, 'rgb(255,0,0)');
+ g.fillStyle = lg; g.fillRect(0, 0, 256, 1);
+ return g.getImageData(0, 0, 256, 1).data;
+ })();
+
+ function draw() {
+ const w = Math.max(de.scrollWidth, de.clientWidth, 1);
+ const h = Math.max(de.scrollHeight, de.clientHeight, 1);
+ lastW = w; lastH = h;
+ // Zeichenflaeche begrenzen (Speicher): hoechstens ca. 4 Mio. Pixel
+ const scale = Math.min(1, Math.sqrt(4e6 / (w * h)));
+ const cw = Math.max(1, Math.floor(w * scale)), ch = Math.max(1, Math.floor(h * scale));
+ canvas.width = cw; canvas.height = ch;
+ cs.width = w + 'px'; cs.height = h + 'px';
+ const g = canvas.getContext('2d');
+ g.clearRect(0, 0, cw, ch);
+ const r = Math.max(6, 30 * scale);
+ for (const p of points) {
+ const x = p.x * cw, y = p.y * ch;
+ const rg = g.createRadialGradient(x, y, 0, x, y, r);
+ rg.addColorStop(0, 'rgba(0,0,0,0.25)'); rg.addColorStop(1, 'rgba(0,0,0,0)');
+ g.fillStyle = rg; g.fillRect(x - r, y - r, r * 2, r * 2);
+ }
+ // Deckkraft-Stufen in Farben umsetzen
+ const img = g.getImageData(0, 0, cw, ch);
+ const d = img.data;
+ for (let i = 3; i < d.length; i += 4) {
+ const a = d[i];
+ if (!a) continue;
+ const k = Math.min(255, a * 3) * 4;
+ d[i - 3] = ramp[k]; d[i - 2] = ramp[k + 1]; d[i - 1] = ramp[k + 2];
+ d[i] = Math.min(200, 60 + a);
+ }
+ g.putImageData(img, 0, 0);
+ }
+
+ function redrawIfChanged() {
+ if (Math.max(de.scrollWidth, de.clientWidth) !== lastW || Math.max(de.scrollHeight, de.clientHeight) !== lastH) draw();
+ }
+ function stop() {
+ clearInterval(timer);
+ window.removeEventListener('resize', draw);
+ host.remove(); canvas.remove();
+ }
+ close.addEventListener('click', stop);
+ window.addEventListener('resize', draw);
+
+ fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
+ .then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
+ .then(function (j) {
+ points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
+ msg.innerHTML = escapeHtml('Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)');
+ draw();
+ timer = setInterval(redrawIfChanged, 1000);
+ })
+ .catch(function () { msg.innerHTML = escapeHtml('Heatmap-Link abgelaufen oder ungültig'); });
+}
+
+try { main(); } catch (e) { /* nie die Seite stoeren */ }
+
+})();
diff --git a/analytics/client/dist/w.js b/analytics/client/dist/w.js
new file mode 100644
index 0000000..df99603
--- /dev/null
+++ b/analytics/client/dist/w.js
@@ -0,0 +1,345 @@
+/* WEBklar Analyse w.js - generiert aus client/core.js + client/w.js (node client/build.js), nicht von Hand aendern */
+(function () {
+'use strict';
+// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
+// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
+
+const CONSENT_KEY = 'wk-consent';
+const CONSENT_MAX_AGE = 183 * 86400000;
+
+function vpClass(width) {
+ const w = Number(width);
+ if (w < 768) return 'm';
+ if (w < 1024) return 't';
+ return 'd';
+}
+
+function cleanPath(href) {
+ let p = '/';
+ try {
+ p = new URL(String(href == null ? '' : href), 'http://x.invalid/').pathname || '/';
+ } catch (e) { /* bleibt '/' */ }
+ try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
+ if (p.charAt(0) !== '/') p = '/' + p;
+ return p.slice(0, 300);
+}
+
+const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
+
+function refHost(referrer, ownHost) {
+ if (!referrer) return '';
+ try {
+ const u = new URL(String(referrer));
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ const h = u.hostname.toLowerCase();
+ if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
+ return h;
+ } catch (e) { return ''; }
+}
+
+// Erzeugt wirkende Namen (css-1a2b3c, ember12345, 9f8e7d6c5b4a ...) erkennen.
+function looksGenerated(s) {
+ if (/\d{4,}/.test(s)) return true;
+ const mixed = (t, min) => t.length >= min && /\d/.test(t) && /[a-z]/i.test(t);
+ if (mixed(s, 10) && !/[-_]/.test(s)) return true;
+ if (/^[a-f0-9]{8,}$/i.test(s) && /\d/.test(s)) return true;
+ return s.split(/[-_]/).some((p) => mixed(p, 6));
+}
+
+const safeName = (s) => String(s).replace(/[^\w-]/g, '');
+
+function cssPath(el) {
+ const parts = [];
+ let n = el;
+ while (n && n.tagName && parts.length < 5) {
+ const tag = String(n.tagName).toLowerCase();
+ let seg = tag;
+ const id = n.id ? safeName(n.id) : '';
+ if (id && !looksGenerated(id)) {
+ parts.unshift(seg + '#' + id);
+ break;
+ }
+ const raw = n.getAttribute ? n.getAttribute('class') : '';
+ const cls = String(raw || '').split(/\s+/).map(safeName).filter((c) => c && !looksGenerated(c)).slice(0, 2);
+ if (cls.length) seg += '.' + cls.join('.');
+ parts.unshift(seg);
+ n = n.parentNode;
+ }
+ let out = parts.join(' > ');
+ while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
+ return out.slice(0, 200);
+}
+
+function parseHref(a) {
+ const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
+ return String(href).trim();
+}
+
+function linkHost(a) {
+ try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
+}
+
+function contactKind(a, ownHost) {
+ const href = parseHref(a);
+ if (!href) return null;
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
+ if (!scheme) return null; // relativ, Anker
+ const s = scheme[1].toLowerCase();
+ if (s === 'tel') return 'tel';
+ if (s === 'mailto') return 'mail';
+ if (s === 'whatsapp') return 'wa';
+ if (s !== 'http' && s !== 'https') return null;
+ const host = linkHost(a);
+ if (!host) return null;
+ if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
+ const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
+ return stripWww(host) === stripWww(own || '') ? null : 'out';
+}
+
+function readConsent(storage, now) {
+ try {
+ const raw = storage.getItem(CONSENT_KEY);
+ if (!raw) return null;
+ const c = JSON.parse(raw);
+ if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
+ const age = now - c.t;
+ if (age < 0 || age >= CONSENT_MAX_AGE) return null;
+ return { v: 1, a: c.a, t: c.t };
+ } catch (e) { return null; }
+}
+
+function contrastOk(hex) {
+ if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
+ const lin = (i) => {
+ const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
+ return 1.05 / (L + 0.05) >= 4.5;
+}
+
+function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
+// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
+
+function main() {
+ const DOC = document;
+ const WIN = window;
+ const SELF = DOC.currentScript && DOC.currentScript.getAttribute && DOC.currentScript.getAttribute('data-site')
+ ? DOC.currentScript : DOC.querySelector('script[src$="/a/w.js"]');
+ if (!SELF) return;
+ const site = String(SELF.getAttribute('data-site') || '');
+ if (!/^[a-z0-9]{16,40}$/.test(site)) return;
+ const colorAttr = String(SELF.getAttribute('data-color') || '');
+ const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
+ const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
+ const base = src.replace(/[^/]*$/, '');
+ const endpoint = base + 'e';
+
+ // ---- Heatmap-Modus: nichts senden, kein Banner ----
+ const qs = new URLSearchParams(WIN.location.search);
+ const heatToken = qs.get('__wk_heat');
+ if (heatToken) {
+ qs.delete('__wk_heat');
+ const rest = qs.toString();
+ try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
+ const s = DOC.createElement('script');
+ s.setAttribute('src', base + 'h.js');
+ s.setAttribute('data-token', heatToken);
+ (DOC.head || DOC.documentElement).appendChild(s);
+ return;
+ }
+
+ // ---- Senden: Fehler werden verschluckt ----
+ function send(obj) {
+ try {
+ const body = JSON.stringify(obj);
+ if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
+ fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
+ } catch (e) { /* egal */ }
+ }
+ const safe = (fn) => function () { try { return fn.apply(this, arguments); } catch (e) { /* nie die Seite stoeren */ } };
+ const vp = () => vpClass(WIN.innerWidth);
+ const ownHost = () => WIN.location.hostname;
+
+ // ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
+ let lastPath = null;
+ let pvStart = Date.now();
+ let maxScroll = 0;
+ let enSent = false;
+ function sendPv(first) {
+ lastPath = cleanPath(WIN.location.pathname);
+ pvStart = Date.now(); maxScroll = 0; enSent = false;
+ send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
+ }
+ let navTimer = null;
+ function scheduleNav() {
+ if (navTimer) return;
+ navTimer = setTimeout(safe(function () {
+ navTimer = null;
+ if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
+ }), 300);
+ }
+ sendPv(true);
+ try {
+ const orig = WIN.history.pushState;
+ WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
+ WIN.addEventListener('popstate', safe(scheduleNav));
+ } catch (e) { /* egal */ }
+
+ // ---- Tracking (nur nach Annehmen erreichbar) ----
+ let tracking = false;
+ const hosts = [];
+ const scrollPct = () => {
+ const h = DOC.documentElement.scrollHeight || 1;
+ return Math.max(0, Math.min(100, Math.round(((WIN.scrollY || 0) + WIN.innerHeight) / h * 100)));
+ };
+ const rel = (v, total) => Math.max(0, Math.min(1, Math.round(v / (total || 1) * 10000) / 10000));
+
+ const onClick = safe(function (e) {
+ const t = e.target;
+ if (!t || hosts.indexOf(t) !== -1) return;
+ const dw = DOC.documentElement.scrollWidth, dh = DOC.documentElement.scrollHeight;
+ const px = typeof e.pageX === 'number' ? e.pageX : (e.clientX || 0) + (WIN.scrollX || 0);
+ const py = typeof e.pageY === 'number' ? e.pageY : (e.clientY || 0) + (WIN.scrollY || 0);
+ const path = cleanPath(WIN.location.pathname);
+ send({ t: 'ck', site, path, vp: vp(), x: rel(px, dw), y: rel(py, dh), sel: cssPath(t) });
+ let a = t;
+ while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
+ const kind = a ? contactKind(a, ownHost()) : null;
+ if (kind) {
+ const ev = { t: 'ct', site, path, kind };
+ if (kind === 'out') ev.host = linkHost(a);
+ send(ev);
+ }
+ });
+ const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
+ const onLeave = safe(function () {
+ if (enSent) return;
+ enSent = true;
+ send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
+ sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
+ });
+ const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
+
+ function startTracking() {
+ if (tracking) return;
+ tracking = true;
+ DOC.addEventListener('click', onClick, { capture: true, passive: true });
+ DOC.addEventListener('visibilitychange', onVis);
+ WIN.addEventListener('pagehide', onLeave);
+ WIN.addEventListener('scroll', onScroll, { passive: true });
+ }
+ function stopTracking() {
+ tracking = false;
+ DOC.removeEventListener('click', onClick, { capture: true });
+ DOC.removeEventListener('visibilitychange', onVis);
+ WIN.removeEventListener('pagehide', onLeave);
+ WIN.removeEventListener('scroll', onScroll);
+ }
+
+ // ---- Einwilligung ----
+ const gpc = navigator.globalPrivacyControl === true || navigator.doNotTrack === '1' || WIN.doNotTrack === '1';
+ if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
+
+ let storage = null;
+ try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
+ const stored = readConsent(storage, Date.now());
+ let bannerHost = null;
+ let linkHostEl = null;
+
+ function choose(a) {
+ try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { /* ohne Speicher: gilt nur fuer diese Seite */ }
+ send({ t: 'bn', site, a });
+ if (a === 'accept') startTracking(); else stopTracking();
+ closeBanner();
+ }
+
+ function el(tag, attrs, text) {
+ const n = DOC.createElement(tag);
+ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
+ if (text != null) n.textContent = text;
+ return n;
+ }
+
+ const CSS = ':host{all:initial}' +
+ '*{box-sizing:border-box}' +
+ '.bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:14px 20px;background:#fff;color:#111;border-top:1px solid #d0d0d0;box-shadow:0 -2px 12px rgba(0,0,0,.12);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}' +
+ '.t{margin:0;max-width:720px}' +
+ '.t a{color:#111;text-decoration:underline}' +
+ '.r{display:flex;gap:10px;flex:none}' +
+ 'button{font:600 15px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-width:120px;height:44px;padding:0 20px;border:2px solid #111;border-radius:8px;cursor:pointer;background:#fff;color:#111}' +
+ 'button.p{background:' + accent + ';border-color:' + accent + ';color:#fff}' +
+ 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
+ '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
+
+ function openBanner() {
+ if (bannerHost) return;
+ const prev = DOC.activeElement;
+ bannerHost = DOC.createElement('div');
+ const st = bannerHost.style;
+ st.position = 'fixed'; st.left = '0'; st.right = '0'; st.bottom = '0'; st.zIndex = '2147483647';
+ hosts.push(bannerHost);
+ const root = bannerHost.attachShadow({ mode: 'open' });
+ root.appendChild(el('style', {}, CSS));
+ const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
+ const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details in der ');
+ p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz-Info'));
+ p.appendChild(DOC.createTextNode ? DOC.createTextNode('.') : el('span', {}, '.'));
+ const row = el('div', { class: 'r' });
+ const no = el('button', { type: 'button' }, 'Ablehnen');
+ const yes = el('button', { type: 'button', class: 'p' }, 'Annehmen');
+ no.addEventListener('click', safe(function () { choose('reject'); if (prev && prev.focus) prev.focus(); }));
+ yes.addEventListener('click', safe(function () { choose('accept'); if (prev && prev.focus) prev.focus(); }));
+ row.appendChild(no); row.appendChild(yes);
+ bar.appendChild(p); bar.appendChild(row);
+ root.appendChild(bar);
+ DOC.body.appendChild(bannerHost);
+ if (linkHostEl) linkHostEl.style.display = 'none';
+ no.focus();
+ }
+ function closeBanner() {
+ if (!bannerHost) return;
+ const i = hosts.indexOf(bannerHost);
+ if (i !== -1) hosts.splice(i, 1);
+ bannerHost.remove();
+ bannerHost = null;
+ if (linkHostEl) linkHostEl.style.display = '';
+ }
+
+ function addSettingsLink() {
+ linkHostEl = DOC.createElement('div');
+ const st = linkHostEl.style;
+ st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
+ hosts.push(linkHostEl);
+ const root = linkHostEl.attachShadow({ mode: 'open' });
+ root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
+ const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
+ b.addEventListener('click', safe(openBanner));
+ root.appendChild(b);
+ DOC.body.appendChild(linkHostEl);
+ }
+
+ if (DOC.querySelector('[data-webklar-consent]')) {
+ DOC.addEventListener('click', safe(function (e) {
+ const t = e.target;
+ if (t && t.closest && t.closest('[data-webklar-consent]')) { if (e.preventDefault) e.preventDefault(); openBanner(); }
+ }));
+ } else {
+ addSettingsLink();
+ }
+
+ if (stored) {
+ if (stored.a === 'accept') startTracking();
+ } else {
+ openBanner();
+ send({ t: 'bn', site, a: 'shown' });
+ }
+}
+
+try { main(); } catch (e) { /* nie die Seite stoeren */ }
+
+})();
diff --git a/analytics/client/h.js b/analytics/client/h.js
new file mode 100644
index 0000000..75b7d47
--- /dev/null
+++ b/analytics/client/h.js
@@ -0,0 +1,99 @@
+// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
+import { escapeHtml } from './core.js';
+
+function main() {
+ const me = document.currentScript;
+ const token = me && me.getAttribute('data-token');
+ const src = String((me && (me.src || me.getAttribute('src'))) || '').replace(/[?#].*$/, '');
+ const base = src.replace(/[^/]*$/, '');
+ if (!token || !base) return;
+
+ const de = document.documentElement;
+ const host = document.createElement('div');
+ host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
+ const root = host.attachShadow({ mode: 'open' });
+ const style = document.createElement('style');
+ style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
+ const bar = document.createElement('div');
+ bar.className = 'bar';
+ const msg = document.createElement('span');
+ msg.innerHTML = escapeHtml('Heatmap wird geladen ...');
+ const close = document.createElement('button');
+ close.setAttribute('type', 'button');
+ close.textContent = 'Schließen';
+ bar.appendChild(msg); bar.appendChild(close);
+ root.appendChild(style); root.appendChild(bar);
+ document.body.appendChild(host);
+
+ const canvas = document.createElement('canvas');
+ const cs = canvas.style;
+ cs.position = 'absolute'; cs.left = '0'; cs.top = '0'; cs.pointerEvents = 'none'; cs.zIndex = '2147483646';
+ document.body.appendChild(canvas);
+
+ let points = [];
+ let lastW = 0, lastH = 0, timer = 0;
+
+ // Farbverlauf: transparent -> blau -> gruen -> gelb -> rot
+ const ramp = (function () {
+ const c = document.createElement('canvas'); c.width = 256; c.height = 1;
+ const g = c.getContext('2d');
+ const lg = g.createLinearGradient(0, 0, 256, 0);
+ lg.addColorStop(0.25, 'rgb(0,0,255)'); lg.addColorStop(0.55, 'rgb(0,255,0)'); lg.addColorStop(0.85, 'rgb(255,255,0)'); lg.addColorStop(1, 'rgb(255,0,0)');
+ g.fillStyle = lg; g.fillRect(0, 0, 256, 1);
+ return g.getImageData(0, 0, 256, 1).data;
+ })();
+
+ function draw() {
+ const w = Math.max(de.scrollWidth, de.clientWidth, 1);
+ const h = Math.max(de.scrollHeight, de.clientHeight, 1);
+ lastW = w; lastH = h;
+ // Zeichenflaeche begrenzen (Speicher): hoechstens ca. 4 Mio. Pixel
+ const scale = Math.min(1, Math.sqrt(4e6 / (w * h)));
+ const cw = Math.max(1, Math.floor(w * scale)), ch = Math.max(1, Math.floor(h * scale));
+ canvas.width = cw; canvas.height = ch;
+ cs.width = w + 'px'; cs.height = h + 'px';
+ const g = canvas.getContext('2d');
+ g.clearRect(0, 0, cw, ch);
+ const r = Math.max(6, 30 * scale);
+ for (const p of points) {
+ const x = p.x * cw, y = p.y * ch;
+ const rg = g.createRadialGradient(x, y, 0, x, y, r);
+ rg.addColorStop(0, 'rgba(0,0,0,0.25)'); rg.addColorStop(1, 'rgba(0,0,0,0)');
+ g.fillStyle = rg; g.fillRect(x - r, y - r, r * 2, r * 2);
+ }
+ // Deckkraft-Stufen in Farben umsetzen
+ const img = g.getImageData(0, 0, cw, ch);
+ const d = img.data;
+ for (let i = 3; i < d.length; i += 4) {
+ const a = d[i];
+ if (!a) continue;
+ const k = Math.min(255, a * 3) * 4;
+ d[i - 3] = ramp[k]; d[i - 2] = ramp[k + 1]; d[i - 1] = ramp[k + 2];
+ d[i] = Math.min(200, 60 + a);
+ }
+ g.putImageData(img, 0, 0);
+ }
+
+ function redrawIfChanged() {
+ if (Math.max(de.scrollWidth, de.clientWidth) !== lastW || Math.max(de.scrollHeight, de.clientHeight) !== lastH) draw();
+ }
+ function stop() {
+ clearInterval(timer);
+ window.removeEventListener('resize', draw);
+ host.remove(); canvas.remove();
+ }
+ close.addEventListener('click', stop);
+ window.addEventListener('resize', draw);
+
+ fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
+ .then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
+ .then(function (j) {
+ points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
+ msg.innerHTML = escapeHtml('Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)');
+ draw();
+ timer = setInterval(redrawIfChanged, 1000);
+ })
+ .catch(function () { msg.innerHTML = escapeHtml('Heatmap-Link abgelaufen oder ungültig'); });
+}
+
+try { main(); } catch (e) { /* nie die Seite stoeren */ }
diff --git a/analytics/client/w.js b/analytics/client/w.js
new file mode 100644
index 0000000..6740759
--- /dev/null
+++ b/analytics/client/w.js
@@ -0,0 +1,220 @@
+// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
+// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
+import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js';
+
+function main() {
+ const DOC = document;
+ const WIN = window;
+ const SELF = DOC.currentScript && DOC.currentScript.getAttribute && DOC.currentScript.getAttribute('data-site')
+ ? DOC.currentScript : DOC.querySelector('script[src$="/a/w.js"]');
+ if (!SELF) return;
+ const site = String(SELF.getAttribute('data-site') || '');
+ if (!/^[a-z0-9]{16,40}$/.test(site)) return;
+ const colorAttr = String(SELF.getAttribute('data-color') || '');
+ const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
+ const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
+ const base = src.replace(/[^/]*$/, '');
+ const endpoint = base + 'e';
+
+ // ---- Heatmap-Modus: nichts senden, kein Banner ----
+ const qs = new URLSearchParams(WIN.location.search);
+ const heatToken = qs.get('__wk_heat');
+ if (heatToken) {
+ qs.delete('__wk_heat');
+ const rest = qs.toString();
+ try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
+ const s = DOC.createElement('script');
+ s.setAttribute('src', base + 'h.js');
+ s.setAttribute('data-token', heatToken);
+ (DOC.head || DOC.documentElement).appendChild(s);
+ return;
+ }
+
+ // ---- Senden: Fehler werden verschluckt ----
+ function send(obj) {
+ try {
+ const body = JSON.stringify(obj);
+ if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
+ fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
+ } catch (e) { /* egal */ }
+ }
+ const safe = (fn) => function () { try { return fn.apply(this, arguments); } catch (e) { /* nie die Seite stoeren */ } };
+ const vp = () => vpClass(WIN.innerWidth);
+ const ownHost = () => WIN.location.hostname;
+
+ // ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
+ let lastPath = null;
+ let pvStart = Date.now();
+ let maxScroll = 0;
+ let enSent = false;
+ function sendPv(first) {
+ lastPath = cleanPath(WIN.location.pathname);
+ pvStart = Date.now(); maxScroll = 0; enSent = false;
+ send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
+ }
+ let navTimer = null;
+ function scheduleNav() {
+ if (navTimer) return;
+ navTimer = setTimeout(safe(function () {
+ navTimer = null;
+ if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
+ }), 300);
+ }
+ sendPv(true);
+ try {
+ const orig = WIN.history.pushState;
+ WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
+ WIN.addEventListener('popstate', safe(scheduleNav));
+ } catch (e) { /* egal */ }
+
+ // ---- Tracking (nur nach Annehmen erreichbar) ----
+ let tracking = false;
+ const hosts = [];
+ const scrollPct = () => {
+ const h = DOC.documentElement.scrollHeight || 1;
+ return Math.max(0, Math.min(100, Math.round(((WIN.scrollY || 0) + WIN.innerHeight) / h * 100)));
+ };
+ const rel = (v, total) => Math.max(0, Math.min(1, Math.round(v / (total || 1) * 10000) / 10000));
+
+ const onClick = safe(function (e) {
+ const t = e.target;
+ if (!t || hosts.indexOf(t) !== -1) return;
+ const dw = DOC.documentElement.scrollWidth, dh = DOC.documentElement.scrollHeight;
+ const px = typeof e.pageX === 'number' ? e.pageX : (e.clientX || 0) + (WIN.scrollX || 0);
+ const py = typeof e.pageY === 'number' ? e.pageY : (e.clientY || 0) + (WIN.scrollY || 0);
+ const path = cleanPath(WIN.location.pathname);
+ send({ t: 'ck', site, path, vp: vp(), x: rel(px, dw), y: rel(py, dh), sel: cssPath(t) });
+ let a = t;
+ while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
+ const kind = a ? contactKind(a, ownHost()) : null;
+ if (kind) {
+ const ev = { t: 'ct', site, path, kind };
+ if (kind === 'out') ev.host = linkHost(a);
+ send(ev);
+ }
+ });
+ const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
+ const onLeave = safe(function () {
+ if (enSent) return;
+ enSent = true;
+ send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
+ sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
+ });
+ const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
+
+ function startTracking() {
+ if (tracking) return;
+ tracking = true;
+ DOC.addEventListener('click', onClick, { capture: true, passive: true });
+ DOC.addEventListener('visibilitychange', onVis);
+ WIN.addEventListener('pagehide', onLeave);
+ WIN.addEventListener('scroll', onScroll, { passive: true });
+ }
+ function stopTracking() {
+ tracking = false;
+ DOC.removeEventListener('click', onClick, { capture: true });
+ DOC.removeEventListener('visibilitychange', onVis);
+ WIN.removeEventListener('pagehide', onLeave);
+ WIN.removeEventListener('scroll', onScroll);
+ }
+
+ // ---- Einwilligung ----
+ const gpc = navigator.globalPrivacyControl === true || navigator.doNotTrack === '1' || WIN.doNotTrack === '1';
+ if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
+
+ let storage = null;
+ try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
+ const stored = readConsent(storage, Date.now());
+ let bannerHost = null;
+ let linkHostEl = null;
+
+ function choose(a) {
+ try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { /* ohne Speicher: gilt nur fuer diese Seite */ }
+ send({ t: 'bn', site, a });
+ if (a === 'accept') startTracking(); else stopTracking();
+ closeBanner();
+ }
+
+ function el(tag, attrs, text) {
+ const n = DOC.createElement(tag);
+ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
+ if (text != null) n.textContent = text;
+ return n;
+ }
+
+ const CSS = ':host{all:initial}' +
+ '*{box-sizing:border-box}' +
+ '.bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:14px 20px;background:#fff;color:#111;border-top:1px solid #d0d0d0;box-shadow:0 -2px 12px rgba(0,0,0,.12);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}' +
+ '.t{margin:0;max-width:720px}' +
+ '.t a{color:#111;text-decoration:underline}' +
+ '.r{display:flex;gap:10px;flex:none}' +
+ 'button{font:600 15px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-width:120px;height:44px;padding:0 20px;border:2px solid #111;border-radius:8px;cursor:pointer;background:#fff;color:#111}' +
+ 'button.p{background:' + accent + ';border-color:' + accent + ';color:#fff}' +
+ 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
+ '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
+
+ function openBanner() {
+ if (bannerHost) return;
+ const prev = DOC.activeElement;
+ bannerHost = DOC.createElement('div');
+ const st = bannerHost.style;
+ st.position = 'fixed'; st.left = '0'; st.right = '0'; st.bottom = '0'; st.zIndex = '2147483647';
+ hosts.push(bannerHost);
+ const root = bannerHost.attachShadow({ mode: 'open' });
+ root.appendChild(el('style', {}, CSS));
+ const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
+ const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details in der ');
+ p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz-Info'));
+ p.appendChild(DOC.createTextNode ? DOC.createTextNode('.') : el('span', {}, '.'));
+ const row = el('div', { class: 'r' });
+ const no = el('button', { type: 'button' }, 'Ablehnen');
+ const yes = el('button', { type: 'button', class: 'p' }, 'Annehmen');
+ no.addEventListener('click', safe(function () { choose('reject'); if (prev && prev.focus) prev.focus(); }));
+ yes.addEventListener('click', safe(function () { choose('accept'); if (prev && prev.focus) prev.focus(); }));
+ row.appendChild(no); row.appendChild(yes);
+ bar.appendChild(p); bar.appendChild(row);
+ root.appendChild(bar);
+ DOC.body.appendChild(bannerHost);
+ if (linkHostEl) linkHostEl.style.display = 'none';
+ no.focus();
+ }
+ function closeBanner() {
+ if (!bannerHost) return;
+ const i = hosts.indexOf(bannerHost);
+ if (i !== -1) hosts.splice(i, 1);
+ bannerHost.remove();
+ bannerHost = null;
+ if (linkHostEl) linkHostEl.style.display = '';
+ }
+
+ function addSettingsLink() {
+ linkHostEl = DOC.createElement('div');
+ const st = linkHostEl.style;
+ st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
+ hosts.push(linkHostEl);
+ const root = linkHostEl.attachShadow({ mode: 'open' });
+ root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
+ const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
+ b.addEventListener('click', safe(openBanner));
+ root.appendChild(b);
+ DOC.body.appendChild(linkHostEl);
+ }
+
+ if (DOC.querySelector('[data-webklar-consent]')) {
+ DOC.addEventListener('click', safe(function (e) {
+ const t = e.target;
+ if (t && t.closest && t.closest('[data-webklar-consent]')) { if (e.preventDefault) e.preventDefault(); openBanner(); }
+ }));
+ } else {
+ addSettingsLink();
+ }
+
+ if (stored) {
+ if (stored.a === 'accept') startTracking();
+ } else {
+ openBanner();
+ send({ t: 'bn', site, a: 'shown' });
+ }
+}
+
+try { main(); } catch (e) { /* nie die Seite stoeren */ }
diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js
new file mode 100644
index 0000000..47676ad
--- /dev/null
+++ b/analytics/test/client.test.js
@@ -0,0 +1,345 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import {
+ vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
+} from '../client/core.js';
+import { buildAll } from '../client/build.js';
+
+const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
+
+// ---------- core ----------
+test('vpClass', () => {
+ assert.equal(vpClass(0), 'm');
+ assert.equal(vpClass(767), 'm');
+ assert.equal(vpClass(768), 't');
+ assert.equal(vpClass(1023), 't');
+ assert.equal(vpClass(1024), 'd');
+ assert.equal(vpClass(NaN), 'd');
+});
+
+test('cleanPath', () => {
+ assert.equal(cleanPath('https://x.de/a/b?q=1#h'), '/a/b');
+ assert.equal(cleanPath('/über%20uns?x=1'), '/über uns');
+ assert.equal(cleanPath('/%E0%A4%A'), '/%E0%A4%A'); // kaputtes Encoding bleibt roh
+ assert.equal(cleanPath('https://x.de'), '/');
+ assert.equal(cleanPath(''), '/');
+ assert.equal(cleanPath('/' + 'a'.repeat(400)).length, 300);
+ assert.equal(cleanPath(null), '/');
+});
+
+test('refHost', () => {
+ assert.equal(refHost('https://www.google.com/search?q=x', 'mein.de'), 'www.google.com');
+ assert.equal(refHost('https://mein.de/a', 'mein.de'), '');
+ assert.equal(refHost('https://www.mein.de/a', 'mein.de'), '');
+ assert.equal(refHost('', 'mein.de'), '');
+ assert.equal(refHost('kein url', 'mein.de'), '');
+ assert.equal(refHost('android-app://com.x', 'mein.de'), '');
+ assert.equal(refHost('HTTPS://Foo.DE/x', 'mein.de'), 'foo.de');
+});
+
+function el(tag, o = {}) {
+ const e = { tagName: tag.toUpperCase(), id: o.id || '', parentNode: o.parent || null, attrs: { class: o.cls || '' },
+ getAttribute(n) { return this.attrs[n] ?? null; }, classList: (o.cls || '').split(/\s+/).filter(Boolean) };
+ return e;
+}
+
+test('cssPath', () => {
+ const body = el('body');
+ const div = el('div', { parent: body, cls: 'hero main' });
+ const btn = el('button', { parent: div, cls: 'cta' });
+ assert.equal(cssPath(btn), 'body > div.hero.main > button.cta');
+ const withId = el('section', { parent: body, id: 'kontakt' });
+ const a = el('a', { parent: withId });
+ assert.equal(cssPath(a), 'section#kontakt > a');
+ // generierte IDs/Klassen werden uebersprungen
+ const gen = el('div', { parent: body, id: 'ember12345', cls: 'css-1a2b3c x9f8e7d6c5b4a ok' });
+ assert.equal(cssPath(gen), 'body > div.ok');
+ // max. 5 Ebenen
+ let p = el('div'); const first = p;
+ for (let i = 0; i < 9; i++) p = el('span', { parent: p });
+ assert.equal(cssPath(p).split(' > ').length, 5);
+ assert.ok(first);
+ // <= 200
+ const long = el('div', { parent: body, cls: Array.from({ length: 40 }, (_, i) => 'klasse' + 'x'.repeat(i % 5 + 3)).join(' ') });
+ assert.ok(cssPath(long).length <= 200);
+ assert.equal(cssPath(null), '');
+});
+
+test('contactKind', () => {
+ const k = (href) => contactKind({ href }, 'mein.de');
+ assert.equal(k('tel:+49123'), 'tel');
+ assert.equal(k('TEL:+49123'), 'tel');
+ assert.equal(k('mailto:a@b.de'), 'mail');
+ assert.equal(k('https://wa.me/49123'), 'wa');
+ assert.equal(k('https://api.whatsapp.com/send?phone=1'), 'wa');
+ assert.equal(k('whatsapp://send?text=x'), 'wa');
+ assert.equal(k('https://anders.de/x'), 'out');
+ assert.equal(k('https://mein.de/x'), null);
+ assert.equal(k('https://www.mein.de/x'), null);
+ assert.equal(k('/kontakt'), null);
+ assert.equal(k('#top'), null);
+ assert.equal(k('javascript:void(0)'), null);
+ assert.equal(k(''), null);
+ assert.equal(contactKind(null, 'mein.de'), null);
+});
+
+test('readConsent', () => {
+ const now = 1_700_000_000_000;
+ const day = 86400000;
+ const st = (v) => ({ getItem: () => v });
+ assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day });
+ assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject');
+ assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null);
+ assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null);
+ assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null);
+ assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null);
+ assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null);
+ assert.equal(readConsent(st('kaputt{'), now), null);
+ assert.equal(readConsent(st(null), now), null);
+ assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null);
+ assert.equal(readConsent(null, now), null);
+});
+
+test('contrastOk', () => {
+ assert.equal(contrastOk('#111111'), true);
+ assert.equal(contrastOk('#ffffff'), false);
+ assert.equal(contrastOk('#ffff00'), false);
+ assert.equal(contrastOk('#767676'), true); // 4.54
+ assert.equal(contrastOk('#777777'), false); // 4.48
+ assert.equal(contrastOk('#00F'), false); // ungueltig
+ assert.equal(contrastOk(undefined), false);
+});
+
+test('escapeHtml', () => {
+ assert.equal(escapeHtml(`&'`), '<a href="x">&'</a>');
+ assert.equal(escapeHtml(null), '');
+});
+
+// ---------- dist ----------
+test('dist ist aktuell', () => {
+ const b = buildAll();
+ assert.equal(readFileSync(join(DIST, 'w.js'), 'utf8'), b.w);
+ assert.equal(readFileSync(join(DIST, 'h.js'), 'utf8'), b.h);
+ assert.ok(!/^\s*(import|export)\s/m.test(b.w + b.h));
+});
+
+// ---------- Verhalten von w.js (Fake-DOM) ----------
+function makeEl(tag) {
+ const e = {
+ tagName: String(tag).toUpperCase(), attrs: {}, children: [], listeners: {}, style: {}, parentNode: null, id: '',
+ textContent: '', focused: false, removed: false,
+ setAttribute(n, v) { this.attrs[n] = String(v); if (n === 'id') this.id = String(v); },
+ getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
+ append(...cs) { cs.forEach((c) => this.appendChild(c)); },
+ addEventListener(t, f) { (this.listeners[t] ||= []).push(f); },
+ removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); },
+ focus() { this.focused = true; },
+ remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); },
+ closest(sel) {
+ const attr = /^\[([\w-]+)\]$/.exec(sel);
+ for (let n = this; n; n = n.parentNode) if (attr && n.attrs && attr[1] in n.attrs) return n;
+ return null;
+ },
+ attachShadow() { const r = makeEl('shadow'); r.isShadow = true; this.shadow = r; return r; },
+ click(extra = {}) { fire(this, 'click', { target: this, ...extra }); },
+ };
+ Object.defineProperty(e, 'href', { get() { return this.attrs.href || ''; } });
+ Object.defineProperty(e, 'className', { set(v) { this.attrs.class = v; }, get() { return this.attrs.class || ''; } });
+ return e;
+}
+function fire(node, type, ev) { for (const f of [...(node.listeners[type] || [])]) f({ type, preventDefault() {}, ...ev }); }
+function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if (n.shadow) walk(n.shadow, fn); }
+function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
+const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
+
+function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) {
+ const sent = []; const store = {}; let now = 1_700_000_000_000;
+ if (stored) store['wk-consent'] = JSON.stringify(stored);
+ const body = makeEl('body'); const html = makeEl('html');
+ const timers = [];
+ const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src;
+ const docL = {}; const winL = {};
+ const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
+ const injected = [];
+ if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); };
+ const document = {
+ currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible',
+ documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
+ createElement: makeEl, activeElement: null,
+ querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
+ addEventListener(t, f) { (docL[t] ||= []).push(f); },
+ removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
+ };
+ body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; };
+ document.head.appendChild = body.appendChild;
+ const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } };
+ const win = {
+ innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
+ location: { hostname: 'mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
+ addEventListener(t, f) { (winL[t] ||= []).push(f); },
+ removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
+ };
+ const sandbox = {
+ window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt,
+ navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } },
+ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
+ setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {},
+ setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(),
+ };
+ win.localStorage = sandbox.localStorage;
+ sandbox.globalThis = sandbox;
+ vm.createContext(sandbox);
+ vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
+ const types = () => sent.map((s) => s.d.t + (s.d.a ? ':' + s.d.a : ''));
+ const ck = (target, ev = {}) => { for (const f of [...(docL.click || [])]) f({ type: 'click', target, pageX: 500, pageY: 1000, clientX: 500, clientY: 700, preventDefault() {}, ...ev }); };
+ const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); };
+ return { sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
+}
+const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; };
+const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length);
+
+test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => {
+ const h = harness();
+ assert.deepEqual(h.types(), ['pv', 'bn:shown']);
+ assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e');
+ assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' });
+ assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' });
+ h.ck(target()); h.leave();
+ assert.deepEqual(h.types(), ['pv', 'bn:shown']);
+ assert.equal((h.docL.click || []).length, 0);
+});
+
+test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => {
+ const h = harness();
+ const host = hostEl(h);
+ assert.ok(host, 'Banner im Shadow Root');
+ const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0];
+ assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen');
+ const all = findAll(host.shadow, () => true);
+ const text = all.map((n) => n.textContent).join(' ');
+ assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/);
+ const a = all.find((n) => n.tagName === 'A');
+ assert.equal(a.attrs.href, '/datenschutz-analyse.html');
+ assert.equal(a.textContent, 'Datenschutz-Info');
+ const css = all.filter((n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
+ assert.match(css, /#0a5a8a/);
+ assert.match(css, /max-width:\s*767px/);
+ const buttons = all.filter((n) => n.tagName === 'BUTTON');
+ assert.deepEqual(buttons.map((b) => b.textContent), ['Ablehnen', 'Annehmen']);
+ assert.ok(buttons[0].focused, 'erster Button fokussiert');
+ assert.equal(host.style.zIndex, '2147483647');
+ // Escape schliesst nicht
+ fire(dlg, 'keydown', { key: 'Escape' }); fire(host, 'keydown', { key: 'Escape' });
+ assert.ok(!host.removed);
+});
+
+test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort', () => {
+ const h = harness();
+ const bh = hostEl(h);
+ btn(bh.shadow, 'Annehmen').click();
+ assert.equal(h.types().at(-1), 'bn:accept');
+ assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 });
+ assert.ok(bh.removed && !hostEl(h));
+ const t = target();
+ h.ck(t);
+ const ck = h.sent.at(-1).d;
+ assert.deepEqual(ck, { t: 'ck', site: 'abcdef0123456789abcd', path: '/start', vp: 'd', x: 0.5, y: 0.5, sel: 'body > button.cta' });
+ // Kontakt-Link: ck + ct (Kind-Element in )
+ const a = makeEl('a'); a.setAttribute('href', 'tel:+4912'); a.parentNode = makeEl('body');
+ const span = makeEl('span'); span.parentNode = a;
+ const n = h.sent.length; h.ck(span);
+ assert.deepEqual(h.sent.slice(n).map((s) => s.d.t), ['ck', 'ct']);
+ assert.deepEqual(h.sent.at(-1).d, { t: 'ct', site: 'abcdef0123456789abcd', path: '/start', kind: 'tel' });
+ const out = makeEl('a'); out.setAttribute('href', 'https://anders.de/x'); out.parentNode = makeEl('body');
+ h.ck(out);
+ assert.deepEqual(h.sent.at(-1).d, { t: 'ct', site: 'abcdef0123456789abcd', path: '/start', kind: 'out', host: 'anders.de' });
+ // en einmal
+ h.leave(); h.leave();
+ const en = h.sent.filter((s) => s.d.t === 'en');
+ assert.equal(en.length, 1);
+ assert.equal(en[0].d.scroll, 55); // (300+800)/2000
+ assert.equal(typeof en[0].d.sec, 'number');
+ // Widerruf ueber Einstellungs-Link
+ const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0];
+ assert.ok(settings, 'fester Link');
+ settings.click();
+ btn(hostEl(h).shadow, 'Ablehnen').click();
+ assert.equal(h.types().at(-1), 'bn:reject');
+ const before = h.sent.length;
+ h.ck(target()); h.leave();
+ assert.equal(h.sent.length, before);
+ assert.equal((h.docL.click || []).length, 0);
+ assert.equal((h.docL.visibilitychange || []).length, 0);
+ assert.equal((h.winL.pagehide || []).length, 0);
+});
+
+test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => {
+ const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
+ assert.deepEqual(h.types(), ['pv']);
+ assert.equal(hostEl(h), undefined);
+ h.ck(target());
+ assert.equal(h.sent.at(-1).d.t, 'ck');
+});
+
+test('w.js: abgelaufene/abgelehnte Einwilligung', () => {
+ const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } });
+ assert.deepEqual(h.types(), ['pv']);
+ h.ck(target()); h.leave();
+ assert.deepEqual(h.types(), ['pv']);
+ const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } });
+ assert.deepEqual(h2.types(), ['pv', 'bn:shown']);
+});
+
+test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => {
+ for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) {
+ const h = harness(o);
+ assert.deepEqual(h.types(), ['pv']);
+ assert.equal(hostEl(h), undefined);
+ assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
+ h.ck(target()); h.leave();
+ assert.deepEqual(h.types(), ['pv']);
+ }
+});
+
+test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
+ const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true });
+ assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
+ assert.equal(hostEl(h), undefined);
+ h.link.click();
+ assert.ok(hostEl(h));
+});
+
+test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () => {
+ const h = harness({ search: '?a=1&__wk_heat=TOKEN.sig' });
+ assert.deepEqual(h.sent, []);
+ assert.equal(hostEl(h), undefined);
+ assert.equal(h.history.replaced, '/start?a=1');
+ assert.equal(h.injected.length, 1);
+ assert.equal(h.injected[0].attrs.src, 'https://project.webklar.com/a/h.js');
+ assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig');
+ assert.equal((h.docL.click || []).length, 0);
+});
+
+test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
+ const h = harness({ gpc: true });
+ h.win.location.pathname = '/start';
+ h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f());
+ assert.equal(h.sent.length, 1);
+ h.win.location.pathname = '/neu';
+ h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu');
+ h.timers.splice(0).forEach((f) => f());
+ assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']);
+});
+
+test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
+ assert.doesNotThrow(() => {
+ const sandbox = { window: {}, document: { currentScript: null, querySelector() { throw new Error('x'); } }, navigator: {}, location: {} };
+ sandbox.window = sandbox; vm.createContext(sandbox);
+ vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
+ });
+});