@kharon-labs/browser 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kharon Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # @kharon-labs/browser
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Personalize parts of any website with Kharon Labs. Mark the regions you want to personalize, keep
4
+ your own copy as the defaults, and the package swaps in the variant Kharon picks for each visitor,
5
+ then measures it against a holdout.
6
+
7
+ ```sh
8
+ npm install @kharon-labs/browser
9
+ ```
10
+
11
+ On a site without a bundler, use the script-tag build at `https://kharonlabs.com/sdk/v1.js`.
12
+
13
+ ## Plain HTML
14
+
15
+ ```html
16
+ <head>
17
+ <!-- Optional: hides slots (never the page) until the swap, for at most 500 ms. -->
18
+ <style id="kharon-af">[data-ge-slot]{visibility:hidden!important}</style>
19
+ <script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("kharon-af");s&&s.parentNode.removeChild(s)},500)</script>
20
+ <script src="https://kharonlabs.com/sdk/v1.js" data-key="ge_pk_…" async></script>
21
+ </head>
22
+ <body>
23
+ <section data-ge-slot="hero">
24
+ <h1 data-ge-field="headline">Grow faster</h1>
25
+ <a data-ge-field="ctaLabel" data-ge-attr="href:ctaUrl" href="/demo">Book a demo</a>
26
+ </section>
27
+ </body>
28
+ ```
29
+
30
+ The anti-flicker snippet is also exported as `ANTI_FLICKER_SNIPPET`.
31
+
32
+ ## Bundlers
33
+
34
+ ```ts
35
+ import { init, setConsent, conversion } from "@kharon-labs/browser";
36
+
37
+ init({ key: "ge_pk_…" });
38
+ myCmp.onChange((ok) => setConsent(ok ? "granted" : "denied"));
39
+ conversion("demo_booked");
40
+ ```
41
+
42
+ | Function | What it does |
43
+ | --- | --- |
44
+ | `init({ key, apiBase?, consent?, timeoutMs?, pageId?, cookieDomain? })` | Resolves the page's slots once the DOM is ready and starts measuring if allowed. |
45
+ | `setConsent("granted" \| "denied" \| "unknown")` | Forward your consent tool's answer. Safe to call before `init`. |
46
+ | `conversion(goal, value?)` | Records a goal. Forms with `data-ge-goal="…"` record theirs on submit. |
47
+ | `refresh()` | Re-scans and re-resolves, e.g. after a client-side route change. |
48
+
49
+ ## Slots
50
+
51
+ - `data-ge-slot="id"` marks a slot; the id matches a slot in the console. Repeat an id to show the
52
+ same decision in several places.
53
+ - `data-ge-field="name"` writes a content field as text (`textContent`), never as HTML.
54
+ - `data-ge-attr="href:ctaUrl, title:ctaTitle"` writes fields into attributes. URL attributes accept
55
+ only `http(s)` and relative links; event handlers and `style` are never written.
56
+ - The markup's own text is the default. A field the variant doesn't set keeps it, and a slot the
57
+ console doesn't know yet keeps all of it.
58
+
59
+ ## Guarantees
60
+
61
+ - **Defaults always render.** If we answer late (after `timeoutMs`, 500 ms), fail or are down, the
62
+ page keeps its defaults and slots are revealed. The package never retries.
63
+ - **Consent is decided by our server**, by the visitor's country and choice (opt-in in the EEA, UK
64
+ and Switzerland, GPC honored). Until it allows storing the visitor, the package writes no cookie
65
+ or storage and sends no analytics.
66
+ - **Holdouts.** Held-out visitors see the defaults; their impressions record the variant withheld.
67
+
68
+ The visitor id is a first-party `ge_vid` cookie on your domain (one year; Safari caps script-set
69
+ cookies at seven days, `@kharon-labs/server` avoids that). A denial deletes it.
70
+
71
+ ## Size and support
72
+
73
+ About 4 KB gzip, no dependencies. ES2020 browsers; without `fetch` the page keeps its defaults, and
74
+ without `IntersectionObserver` impressions aren't recorded.
package/dist/index.cjs ADDED
@@ -0,0 +1,579 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // packages/browser/src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ANTI_FLICKER_SNIPPET: () => ANTI_FLICKER_SNIPPET,
24
+ DEFAULT_API_BASE: () => DEFAULT_API_BASE,
25
+ DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
26
+ VISITOR_COOKIE: () => VISITOR_COOKIE,
27
+ antiFlickerStart: () => antiFlickerStart,
28
+ applyDecision: () => applyDecision,
29
+ conversion: () => conversion,
30
+ createClient: () => createClient,
31
+ hashSlotDefaults: () => hashSlotDefaults,
32
+ init: () => init,
33
+ isWritable: () => isWritable,
34
+ pageIdFor: () => pageIdFor,
35
+ parseResolveResponse: () => parseResolveResponse,
36
+ refresh: () => refresh,
37
+ reveal: () => reveal,
38
+ scanSlots: () => scanSlots,
39
+ setConsent: () => setConsent
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
43
+ // src/engine/analytics/beacon.ts
44
+ var BEACON_VERSION = 1;
45
+ var MAX_BEACON_EVENTS = 50;
46
+ var SLOT_ATTRIBUTES = {
47
+ slot: "data-ge-slot",
48
+ variant: "data-ge-variant",
49
+ counterfactual: "data-ge-counterfactual",
50
+ /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
51
+ reason: "data-ge-reason"
52
+ };
53
+
54
+ // packages/browser/src/tracker.ts
55
+ var COLLECT_PATH = "/api/collect";
56
+ var SESSION_KEY = "ge_sid";
57
+ var PIN_KEY = "ge_pin";
58
+ var VISIBLE_RATIO = 0.5;
59
+ var FLUSH_INTERVAL_MS = 2e3;
60
+ function startTracker({
61
+ pageId,
62
+ configVersion,
63
+ endpoint = COLLECT_PATH,
64
+ root = document,
65
+ match,
66
+ envelope = (events) => ({ v: BEACON_VERSION, events, ...match ? { m: match } : {} }),
67
+ crossOrigin = false
68
+ }) {
69
+ const win = root.defaultView ?? window;
70
+ const sessionId = sessionIdFor(win);
71
+ const queue = [];
72
+ const seenSlots = /* @__PURE__ */ new Set();
73
+ const push = (type, body) => {
74
+ queue.push({ type, sessionId, ...body });
75
+ if (queue.length >= MAX_BEACON_EVENTS) flush();
76
+ };
77
+ const flush = () => {
78
+ while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
79
+ };
80
+ push("page_view", { pageId, configVersion });
81
+ const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
82
+ const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
83
+ (entries) => {
84
+ for (const entry of entries) {
85
+ if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) continue;
86
+ const slot = readSlot(entry.target);
87
+ observer?.unobserve(entry.target);
88
+ if (!slot || seenSlots.has(slot.slotId)) continue;
89
+ seenSlots.add(slot.slotId);
90
+ push("slot_impression", slot);
91
+ }
92
+ },
93
+ { threshold: VISIBLE_RATIO }
94
+ ) : null;
95
+ for (const slot of slots) observer?.observe(slot);
96
+ const interaction = (action) => (event) => {
97
+ const slot = closestSlot(event.target);
98
+ if (slot) push("interaction", { slotId: slot.slotId, variantId: slot.variantId, action });
99
+ };
100
+ const onClick = interaction("click");
101
+ const onCopy = interaction("copy");
102
+ const onToggle = (event) => {
103
+ if (event.target.open) interaction("expand")(event);
104
+ };
105
+ const onSubmit = (event) => {
106
+ const goal = event.target.closest?.("form")?.getAttribute("data-ge-goal");
107
+ if (goal) conversion2(goal);
108
+ };
109
+ const onHide = () => {
110
+ if (root.visibilityState === "hidden") flush();
111
+ };
112
+ const conversion2 = (goal, value) => {
113
+ push("conversion", value === void 0 ? { goal } : { goal, value });
114
+ flush();
115
+ };
116
+ root.addEventListener("click", onClick, true);
117
+ root.addEventListener("copy", onCopy, true);
118
+ root.addEventListener("toggle", onToggle, true);
119
+ root.addEventListener("submit", onSubmit, true);
120
+ root.addEventListener("visibilitychange", onHide);
121
+ win.addEventListener("pagehide", flush);
122
+ const timer = win.setInterval(flush, FLUSH_INTERVAL_MS);
123
+ return {
124
+ conversion: conversion2,
125
+ flush,
126
+ stop({ discard = false } = {}) {
127
+ if (discard) queue.length = 0;
128
+ flush();
129
+ observer?.disconnect();
130
+ win.clearInterval(timer);
131
+ root.removeEventListener("click", onClick, true);
132
+ root.removeEventListener("copy", onCopy, true);
133
+ root.removeEventListener("toggle", onToggle, true);
134
+ root.removeEventListener("submit", onSubmit, true);
135
+ root.removeEventListener("visibilitychange", onHide);
136
+ win.removeEventListener("pagehide", flush);
137
+ }
138
+ };
139
+ }
140
+ function readSlot(el) {
141
+ const slotId = el.getAttribute(SLOT_ATTRIBUTES.slot);
142
+ if (!slotId) return null;
143
+ return {
144
+ slotId,
145
+ variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
146
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
147
+ };
148
+ }
149
+ function closestSlot(target) {
150
+ const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
151
+ return el ? readSlot(el) : null;
152
+ }
153
+ function sessionIdFor(win) {
154
+ try {
155
+ const existing = win.sessionStorage.getItem(SESSION_KEY);
156
+ if (existing) return existing;
157
+ const id = uuid(win);
158
+ win.sessionStorage.setItem(SESSION_KEY, id);
159
+ return id;
160
+ } catch {
161
+ return uuid(win);
162
+ }
163
+ }
164
+ function uuid(win) {
165
+ if (typeof win.crypto.randomUUID === "function") return win.crypto.randomUUID();
166
+ const b = win.crypto.getRandomValues(new Uint8Array(16));
167
+ b[6] = b[6] & 15 | 64;
168
+ b[8] = b[8] & 63 | 128;
169
+ const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
170
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
171
+ }
172
+ function send(win, endpoint, payload, crossOrigin) {
173
+ const body = JSON.stringify(payload);
174
+ const type = crossOrigin ? "text/plain" : "application/json";
175
+ if (win.navigator.sendBeacon?.(endpoint, new Blob([body], { type }))) return;
176
+ void win.fetch(endpoint, {
177
+ method: "POST",
178
+ body,
179
+ keepalive: true,
180
+ credentials: crossOrigin ? "omit" : "same-origin",
181
+ headers: { "content-type": type }
182
+ }).catch((error) => console.warn("[ge] analytics beacon failed", error));
183
+ }
184
+
185
+ // src/engine/snippet/wire.ts
186
+ var RESOLVE_API_VERSION = 1;
187
+ var PREVIEW_PARAM = "ge_preview";
188
+ var BEACON_V2_VERSION = 2;
189
+ var VISITOR_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
190
+ function isVisitorId(value) {
191
+ return typeof value === "string" && VISITOR_ID_PATTERN.test(value);
192
+ }
193
+ function hashSlotDefaults(content) {
194
+ const canonical = JSON.stringify(Object.keys(content).sort().map((field) => [field, content[field]]));
195
+ let hash = 2166136261;
196
+ for (let i = 0; i < canonical.length; i++) {
197
+ hash ^= canonical.charCodeAt(i);
198
+ hash = Math.imul(hash, 16777619);
199
+ }
200
+ return (hash >>> 0).toString(16).padStart(8, "0");
201
+ }
202
+
203
+ // packages/browser/src/wire.ts
204
+ var RESOLVE_PATH = "/api/v1/resolve";
205
+ var COLLECT_V2_PATH = "/api/v1/collect";
206
+ var REASONS = ["matched", "default", "holdout"];
207
+ function parseResolveResponse(body) {
208
+ if (!isRecord(body) || body.v !== RESOLVE_API_VERSION || !isRecord(body.decisions)) return null;
209
+ if (!Number.isSafeInteger(body.configVersion) || typeof body.persist !== "boolean") return null;
210
+ const vid = isVisitorId(body.vid) ? body.vid : void 0;
211
+ const persist = body.persist && vid !== void 0;
212
+ const pin = persist && typeof body.pin === "string" && /^\d{1,9}\.\d{1,9}$/.test(body.pin) ? body.pin : void 0;
213
+ const decisions = {};
214
+ for (const [id, raw] of Object.entries(body.decisions)) {
215
+ const decision = parseDecision(raw);
216
+ if (decision) decisions[id] = decision;
217
+ }
218
+ const m = persist && typeof body.m === "string" && body.m.length <= 1024 ? body.m : void 0;
219
+ if (body.preview === true) return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions };
220
+ return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist, vid, ...pin ? { pin } : {}, decisions, ...m ? { m } : {} };
221
+ }
222
+ function parseDecision(raw) {
223
+ if (!isRecord(raw) || !isRecord(raw.content)) return null;
224
+ if (!isOptionalString(raw.variantId) || !isOptionalString(raw.counterfactualVariantId)) return null;
225
+ if (!REASONS.includes(raw.reason)) return null;
226
+ const content = {};
227
+ for (const [field, value] of Object.entries(raw.content)) if (typeof value === "string") content[field] = value;
228
+ return {
229
+ variantId: raw.variantId,
230
+ counterfactualVariantId: raw.counterfactualVariantId,
231
+ reason: raw.reason,
232
+ content
233
+ };
234
+ }
235
+ function isRecord(value) {
236
+ return typeof value === "object" && value !== null && !Array.isArray(value);
237
+ }
238
+ function isOptionalString(value) {
239
+ return value === null || typeof value === "string";
240
+ }
241
+
242
+ // packages/browser/src/client.ts
243
+ var DEFAULT_API_BASE = "https://kharonlabs.com";
244
+ var DEFAULT_TIMEOUT_MS = 500;
245
+ var BACKGROUND_TIMEOUT_MS = 5e3;
246
+ var VISITOR_COOKIE = "ge_vid";
247
+ var COOKIE_MAX_AGE_S = 365 * 24 * 60 * 60;
248
+ function createClient(options) {
249
+ const doc = options.root ?? document;
250
+ const win = doc.defaultView ?? window;
251
+ const apiBase = (options.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
252
+ const listeners = /* @__PURE__ */ new Set();
253
+ let consent = options.consent ?? "unknown";
254
+ let persisted = false;
255
+ let match;
256
+ let tracker = null;
257
+ let previewing = false;
258
+ const stopTracking = (discard = false) => {
259
+ tracker?.stop({ discard });
260
+ tracker = null;
261
+ };
262
+ const forget = () => {
263
+ persisted = false;
264
+ match = void 0;
265
+ stopTracking(true);
266
+ deleteCookie(doc, VISITOR_COOKIE, options.cookieDomain);
267
+ try {
268
+ win.sessionStorage.removeItem(SESSION_KEY);
269
+ win.sessionStorage.removeItem(PIN_KEY);
270
+ } catch {
271
+ }
272
+ };
273
+ const adopt = (response) => {
274
+ if (response.preview) {
275
+ previewing = true;
276
+ stopTracking(true);
277
+ return;
278
+ }
279
+ if (response.persist && response.vid && consent !== "denied") {
280
+ persisted = true;
281
+ match = response.m;
282
+ if (readCookie(doc, VISITOR_COOKIE) !== response.vid) setCookie(doc, VISITOR_COOKIE, response.vid, options.cookieDomain);
283
+ if (response.pin) writeSession(win, PIN_KEY, response.pin);
284
+ } else if (persisted || readCookie(doc, VISITOR_COOKIE)) {
285
+ forget();
286
+ }
287
+ };
288
+ return {
289
+ get consent() {
290
+ return consent;
291
+ },
292
+ async resolve(slots, deadline = Date.now() + BACKGROUND_TIMEOUT_MS) {
293
+ const vid = readCookie(doc, VISITOR_COOKIE);
294
+ const pin = readSession(win, PIN_KEY);
295
+ const page = previewPage(win);
296
+ const body = {
297
+ v: RESOLVE_API_VERSION,
298
+ key: options.key,
299
+ ...isVisitorId(vid) ? { vid } : {},
300
+ ...pin ? { pin } : {},
301
+ url: page.url,
302
+ referrer: doc.referrer,
303
+ slots: slots.map((slot) => ({ id: slot.id, h: hashSlotDefaults(slot.defaults) })),
304
+ consent,
305
+ gpc: win.navigator.globalPrivacyControl === true
306
+ };
307
+ const remaining = deadline - Date.now();
308
+ if (remaining <= 0 || typeof win.fetch !== "function") return null;
309
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
310
+ let timer;
311
+ try {
312
+ const request = win.fetch(apiBase + RESOLVE_PATH, { method: "POST", body: JSON.stringify(body), credentials: "omit", signal: controller?.signal });
313
+ const timeout = new Promise((resolve) => {
314
+ timer = setTimeout(() => {
315
+ controller?.abort();
316
+ resolve(null);
317
+ }, remaining);
318
+ });
319
+ const res = await Promise.race([request, timeout]);
320
+ if (!res) return null;
321
+ if (!res.ok) {
322
+ console.warn(`[kharon] resolve answered ${res.status}; keeping defaults`);
323
+ return null;
324
+ }
325
+ const parsed = parseResolveResponse(await Promise.race([res.json(), timeout]));
326
+ if (!parsed || Date.now() > deadline) return null;
327
+ if (parsed.preview && page.token) writeSession(win, PREVIEW_PARAM, page.token);
328
+ else if (!parsed.preview && page.token) removeSession(win, PREVIEW_PARAM);
329
+ adopt(parsed);
330
+ return parsed;
331
+ } catch (error) {
332
+ if (!controller?.signal.aborted) console.warn("[kharon] resolve failed; keeping defaults", error);
333
+ return null;
334
+ } finally {
335
+ clearTimeout(timer);
336
+ }
337
+ },
338
+ adopt,
339
+ track(configVersion) {
340
+ stopTracking();
341
+ const vid = readCookie(doc, VISITOR_COOKIE);
342
+ if (previewing || !persisted || !isVisitorId(vid)) return;
343
+ tracker = startTracker({
344
+ pageId: options.pageId ?? pageIdFor(win.location.pathname),
345
+ configVersion,
346
+ endpoint: apiBase + COLLECT_V2_PATH,
347
+ root: doc,
348
+ crossOrigin: true,
349
+ envelope: (events) => ({ v: BEACON_V2_VERSION, key: options.key, vid, consent, events, ...match ? { m: match } : {} })
350
+ });
351
+ },
352
+ setConsent(next) {
353
+ if (next === consent) return;
354
+ consent = next;
355
+ if (next === "denied") forget();
356
+ for (const listener of listeners) listener(next);
357
+ },
358
+ onConsentChange(listener) {
359
+ listeners.add(listener);
360
+ return () => listeners.delete(listener);
361
+ },
362
+ conversion(goal, value) {
363
+ tracker?.conversion(goal, value);
364
+ },
365
+ stop: () => stopTracking()
366
+ };
367
+ }
368
+ function pageIdFor(pathname) {
369
+ const id = pathname.split("/").map((part) => part.replace(/[^A-Za-z0-9_:-]/g, "")).filter(Boolean).join(".").slice(0, 128);
370
+ return id || "home";
371
+ }
372
+ function readCookie(doc, name) {
373
+ for (const part of doc.cookie.split(";")) {
374
+ const [k, ...v] = part.trim().split("=");
375
+ if (k !== name) continue;
376
+ try {
377
+ return decodeURIComponent(v.join("="));
378
+ } catch {
379
+ return void 0;
380
+ }
381
+ }
382
+ return void 0;
383
+ }
384
+ function readSession(win, key) {
385
+ try {
386
+ return win.sessionStorage.getItem(key);
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+ function previewPage(win) {
392
+ const url = win.location.href;
393
+ let parsed;
394
+ try {
395
+ parsed = new URL(url);
396
+ } catch {
397
+ return { url, token: null };
398
+ }
399
+ const inUrl = parsed.searchParams.get(PREVIEW_PARAM);
400
+ if (inUrl) return { url, token: inUrl };
401
+ const stored = readSession(win, PREVIEW_PARAM);
402
+ if (!stored) return { url, token: null };
403
+ parsed.searchParams.set(PREVIEW_PARAM, stored);
404
+ return { url: parsed.toString(), token: stored };
405
+ }
406
+ function removeSession(win, key) {
407
+ try {
408
+ win.sessionStorage.removeItem(key);
409
+ } catch {
410
+ }
411
+ }
412
+ function writeSession(win, key, value) {
413
+ try {
414
+ win.sessionStorage.setItem(key, value);
415
+ } catch {
416
+ }
417
+ }
418
+ function cookieSuffix(doc, domain) {
419
+ const secure = doc.location?.protocol === "https:" ? "; Secure" : "";
420
+ return `; Path=/; SameSite=Lax${domain ? `; Domain=${domain}` : ""}${secure}`;
421
+ }
422
+ function setCookie(doc, name, value, domain) {
423
+ doc.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${COOKIE_MAX_AGE_S}${cookieSuffix(doc, domain)}`;
424
+ }
425
+ function deleteCookie(doc, name, domain) {
426
+ doc.cookie = `${name}=; Max-Age=0${cookieSuffix(doc, domain)}`;
427
+ }
428
+
429
+ // packages/browser/src/dom.ts
430
+ var FIELD_ATTRIBUTE = "data-ge-field";
431
+ var ATTR_ATTRIBUTE = "data-ge-attr";
432
+ var ANTI_FLICKER_ID = "kharon-af";
433
+ var ANTI_FLICKER_SNIPPET = `<style id="${ANTI_FLICKER_ID}">[${SLOT_ATTRIBUTES.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${ANTI_FLICKER_ID}");s&&s.parentNode.removeChild(s)},500)<\/script>`;
434
+ var URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "src", "action", "formaction", "poster", "cite", "xlink:href"]);
435
+ var FORBIDDEN_ATTRIBUTES = /^(on|style$|srcdoc$|srcset$)/;
436
+ var originals = /* @__PURE__ */ new WeakMap();
437
+ var TEXT = "#text";
438
+ function currentValue(el, attr) {
439
+ return attr === null ? (el.textContent ?? "").trim() : el.getAttribute(attr);
440
+ }
441
+ function originalValue(el, attr) {
442
+ const saved = originals.get(el);
443
+ return saved?.has(attr ?? TEXT) ? saved.get(attr ?? TEXT) : currentValue(el, attr);
444
+ }
445
+ function write(el, attr, value) {
446
+ let saved = originals.get(el);
447
+ if (!saved) originals.set(el, saved = /* @__PURE__ */ new Map());
448
+ if (!saved.has(attr ?? TEXT)) saved.set(attr ?? TEXT, currentValue(el, attr));
449
+ if (attr === null) el.textContent = value ?? "";
450
+ else if (value === null) el.removeAttribute(attr);
451
+ else el.setAttribute(attr, value);
452
+ }
453
+ function scanSlots(root) {
454
+ const slots = /* @__PURE__ */ new Map();
455
+ for (const el of slotElements(root)) {
456
+ const id = el.getAttribute(SLOT_ATTRIBUTES.slot);
457
+ if (slots.has(id)) continue;
458
+ const defaults = {};
459
+ for (const binding of bindings(el)) {
460
+ if (binding.field in defaults) continue;
461
+ defaults[binding.field] = originalValue(binding.el, binding.attr) ?? "";
462
+ }
463
+ slots.set(id, { id, defaults });
464
+ }
465
+ return [...slots.values()];
466
+ }
467
+ function applyDecisions(root, response) {
468
+ for (const el of slotElements(root)) {
469
+ const decision = response.decisions[el.getAttribute(SLOT_ATTRIBUTES.slot)];
470
+ if (decision) applyDecision(el, decision, root.baseURI);
471
+ }
472
+ }
473
+ function applyDecision(el, decision, baseURI) {
474
+ for (const { el: target, field, attr } of bindings(el)) {
475
+ const value = decision.content[field];
476
+ if (typeof value === "string" && (attr === null || isWritable(attr, value, baseURI))) write(target, attr, value);
477
+ else if (originals.get(target)?.has(attr ?? TEXT)) write(target, attr, originalValue(target, attr));
478
+ }
479
+ el.setAttribute(SLOT_ATTRIBUTES.variant, decision.variantId ?? "");
480
+ el.setAttribute(SLOT_ATTRIBUTES.counterfactual, decision.counterfactualVariantId ?? "");
481
+ el.setAttribute(SLOT_ATTRIBUTES.reason, decision.reason);
482
+ }
483
+ function reveal(root) {
484
+ root.getElementById(ANTI_FLICKER_ID)?.remove();
485
+ }
486
+ function antiFlickerStart(root) {
487
+ const start = root.defaultView?.__kharonAf;
488
+ return typeof start === "number" ? start : void 0;
489
+ }
490
+ function isWritable(attr, value, baseURI) {
491
+ const name = attr.toLowerCase();
492
+ if (FORBIDDEN_ATTRIBUTES.test(name)) return false;
493
+ if (!URL_ATTRIBUTES.has(name)) return true;
494
+ try {
495
+ const { protocol } = new URL(value, baseURI);
496
+ return protocol === "http:" || protocol === "https:";
497
+ } catch {
498
+ return false;
499
+ }
500
+ }
501
+ function slotElements(root) {
502
+ return Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
503
+ }
504
+ function bindings(slot) {
505
+ const result = [];
506
+ const candidates = [slot, ...Array.from(slot.querySelectorAll(`[${FIELD_ATTRIBUTE}],[${ATTR_ATTRIBUTE}]`))];
507
+ for (const el of candidates) {
508
+ if (el !== slot && el.closest(`[${SLOT_ATTRIBUTES.slot}]`) !== slot) continue;
509
+ const field = el.getAttribute(FIELD_ATTRIBUTE);
510
+ if (field) result.push({ el, field, attr: null });
511
+ for (const pair of (el.getAttribute(ATTR_ATTRIBUTE) ?? "").split(",")) {
512
+ const at = pair.lastIndexOf(":");
513
+ const attr = pair.slice(0, at).trim();
514
+ const mapped = pair.slice(at + 1).trim();
515
+ if (at > 0 && attr && mapped) result.push({ el, field: mapped, attr });
516
+ }
517
+ }
518
+ return result;
519
+ }
520
+
521
+ // packages/browser/src/index.ts
522
+ var current = null;
523
+ var pendingConsent;
524
+ function init(options) {
525
+ if (current) {
526
+ console.warn("[kharon] init called twice; keeping the first instance");
527
+ return current;
528
+ }
529
+ const doc = options.root ?? document;
530
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
531
+ const client = createClient({ ...options, consent: pendingConsent ?? options.consent });
532
+ const run = async (deadline) => {
533
+ try {
534
+ const response = await client.resolve(scanSlots(doc), deadline);
535
+ if (response) applyDecisions(doc, response);
536
+ reveal(doc);
537
+ if (response) client.track(response.configVersion);
538
+ } catch (error) {
539
+ reveal(doc);
540
+ console.warn("[kharon] personalization failed; keeping defaults", error);
541
+ }
542
+ };
543
+ const ready = new Promise((resolve) => {
544
+ if (doc.readyState !== "loading") resolve();
545
+ else doc.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
546
+ });
547
+ let pending = ready.then(() => run((antiFlickerStart(doc) ?? Date.now()) + timeoutMs));
548
+ client.onConsentChange((consent) => {
549
+ if (consent === "granted") pending = pending.then(() => run());
550
+ });
551
+ const instance = {
552
+ client,
553
+ get ready() {
554
+ return pending;
555
+ },
556
+ setConsent: client.setConsent,
557
+ conversion: client.conversion,
558
+ refresh() {
559
+ pending = pending.then(() => run(Date.now() + timeoutMs));
560
+ return pending;
561
+ },
562
+ stop() {
563
+ client.stop();
564
+ if (current === instance) current = null;
565
+ }
566
+ };
567
+ current = instance;
568
+ return instance;
569
+ }
570
+ function setConsent(consent) {
571
+ if (current) current.setConsent(consent);
572
+ else pendingConsent = consent;
573
+ }
574
+ function conversion(goal, value) {
575
+ current?.conversion(goal, value);
576
+ }
577
+ function refresh() {
578
+ return current?.refresh() ?? Promise.resolve();
579
+ }