@webority/analytics 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webority-analytics-site.global.js +1 -0
- package/package.json +14 -7
- package/src/analytics.js +49 -6
- package/src/attribution.js +187 -0
- package/src/consent-regions.js +47 -0
- package/src/consent.js +196 -0
- package/src/index.js +13 -1
- package/src/posthog-bridge.js +1 -1
- package/src/site.js +6 -0
- package/src/state.js +11 -11
- package/src/tags.js +120 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var WeborityAnalytics=(()=>{var m=Object.defineProperty;var M=Object.getOwnPropertyDescriptor;var $=Object.getOwnPropertyNames;var j=Object.prototype.hasOwnProperty;var G=(t,e)=>{for(var n in e)m(t,n,{get:e[n],enumerable:!0})},K=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of $(e))!j.call(t,r)&&r!==n&&m(t,r,{get:()=>e[r],enumerable:!(o=M(e,r))||o.enumerable});return t};var P=t=>K(m({},"__esModule",{value:!0}),t);var ct={};G(ct,{CONSENT_CHANGE_EVENT:()=>y,CONSENT_COOKIE_NAME:()=>u,captureAttribution:()=>x,getAttribution:()=>R,getConsent:()=>a,initConsent:()=>N,loadTags:()=>q,onConsentChange:()=>c,requiresOptIn:()=>b,saveConsent:()=>L});var p=Object.freeze(["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB","CH"]);function b(t){let e=typeof t=="string"?t.trim().toUpperCase():"";return e.length!==2||e==="XX"||e==="T1"?!0:p.includes(e)}var u="webority_cookie_consent",y="webority-consent-change",H=365,i={initialized:!1,requiresOptIn:!0,cookieDomain:null,saved:null},g=new Set;function N(t){if(typeof t?.requiresOptIn!="boolean")throw new Error("@webority/analytics: initConsent needs requiresOptIn (true or false), decided from the visitor's country.");if(t.cookieDomain!==void 0&&(typeof t.cookieDomain!="string"||!t.cookieDomain.trim()))throw new Error('@webority/analytics: initConsent cookieDomain must be a domain such as ".example.com", or left out.');i.initialized=!0,i.requiresOptIn=t.requiresOptIn,i.cookieDomain=t.cookieDomain?.trim()??null,i.saved=X(),B(),i.saved&&v(i.saved)}function l(){return i.initialized}function a(){k("getConsent");let t=i.saved;if(t)return{essential:!0,analytics:t.analytics,functional:t.functional,marketing:t.marketing,decided:!0};let e=!i.requiresOptIn;return{essential:!0,analytics:e,functional:e,marketing:e,decided:!1}}function L(t){k("saveConsent");let e={essential:!0,analytics:t?.analytics===!0,functional:t?.functional===!0,marketing:t?.marketing===!0,timestamp:new Date().toISOString()};J(e),i.saved=e,v(e),Y(a())}function c(t){return g.add(t),()=>g.delete(t)}function Y(t){for(let e of g)try{e(t)}catch(n){queueMicrotask(()=>{throw n})}typeof window?.dispatchEvent=="function"&&typeof CustomEvent=="function"&&window.dispatchEvent(new CustomEvent(y,{detail:t}))}function k(t){if(!i.initialized)throw new Error(`@webority/analytics: call initConsent before ${t}.`)}function w(){window.dataLayer=window.dataLayer||[],window.dataLayer.push(arguments)}function B(){w("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",functionality_storage:"denied",personalization_storage:"denied",security_storage:"granted",wait_for_update:500,region:[...p]}),w("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted",functionality_storage:"granted",personalization_storage:"granted",security_storage:"granted"})}function v(t){let e=n=>n?"granted":"denied";w("consent","update",{analytics_storage:e(t.analytics),functionality_storage:e(t.functional),personalization_storage:e(t.functional),ad_storage:e(t.marketing),ad_user_data:e(t.marketing),ad_personalization:e(t.marketing)})}function X(){let t=`${u}=`,e=(document.cookie||"").split(";").map(n=>n.trim()).find(n=>n.startsWith(t));if(!e)return null;try{let n=JSON.parse(decodeURIComponent(e.slice(t.length)));return!n||typeof n!="object"?null:{essential:!0,analytics:n.analytics===!0,functional:n.functional===!0,marketing:n.marketing===!0,timestamp:n.timestamp??null}}catch{return null}}function J(t){let e=new Date(Date.now()+H*24*60*60*1e3).toUTCString(),n=i.cookieDomain?`;domain=${i.cookieDomain}`:"",o=window.location?.protocol==="https:"?";Secure":"";document.cookie=`${u}=${encodeURIComponent(JSON.stringify(t))};expires=${e};path=/${n};SameSite=Lax${o}`}var f="webority_attribution",V=2160*60*60*1e3,Q=200,C=500,W=Object.freeze({utm_source:"utmSource",utm_medium:"utmMedium",utm_campaign:"utmCampaign",utm_content:"utmContent",utm_term:"utmTerm",gclid:"gclid",gbraid:"gbraid",wbraid:"wbraid",fbclid:"fbclid",msclkid:"msclkid",li_fat_id:"liFatId"}),A=!1;function x(){U("captureAttribution"),et();let t=Z();if(!t)return;let e=I(_());z(_(),{firstTouch:e?.firstTouch??t,lastTouch:t})}function R(){return U("getAttribution"),I(_())}function Z(){let t=new URLSearchParams(window.location.search),e={};for(let[o,r]of Object.entries(W)){let O=d(t.get(o),Q);O&&(e[r]=O)}let n=d(t.get("referrer"),C)||tt();return n&&(e.referrer=n),Object.keys(e).length===0?null:(e.landingPage=d(window.location.pathname,C),e.capturedDateTimeUtc=new Date().toISOString(),e)}function tt(){let t=document.referrer;if(!t)return"";try{return new URL(t).hostname===window.location.hostname?"":d(t,C)}catch{return""}}function et(){A||(A=!0,c(t=>{let e=t.marketing?T():h(),n=t.marketing?h():T(),o=I(e);nt(e),o&&z(n,o)}))}function _(){return a().marketing?h():T()}function h(){try{return window.localStorage}catch{return null}}function T(){try{return window.sessionStorage}catch{return null}}function I(t){try{let e=t?.getItem(f);if(!e)return null;let n=JSON.parse(e),o=D(n?.firstTouch),r=D(n?.lastTouch);return!o&&!r?(t.removeItem(f),null):{firstTouch:o??r,lastTouch:r??o}}catch{return null}}function z(t,e){try{t?.setItem(f,JSON.stringify(e))}catch{}}function nt(t){try{t?.removeItem(f)}catch{}}function D(t){if(!t||typeof t!="object")return null;let e=Date.parse(t.capturedDateTimeUtc);return Number.isFinite(e)&&Date.now()-e<=V?t:null}function d(t,e){let n=String(t??"").trim();return n?n.slice(0,e):""}function U(t){if(!l())throw new Error(`@webority/analytics: call initConsent before ${t}.`)}var ot=["scroll","mousemove","touchstart","keydown","pointerdown"],F=1500,rt=3e3,it=100,s={loaded:!1,clarityId:null,clarityLoaded:!1};function q(t={}){if(!l())throw new Error("@webority/analytics: call initConsent before loadTags.");if(s.loaded)return;s.loaded=!0;let{gtmId:e,clarityId:n,ahrefsKey:o}=t;o&&S("https://analytics.ahrefs.com/analytics.js",{"data-key":o}),e&&at(()=>{window.dataLayer=window.dataLayer||[],window.dataLayer.push({"gtm.start":Date.now(),event:"gtm.js"}),S(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(e)}`)}),n&&(s.clarityId=n,a().analytics&&st(),c(r=>{r.analytics&&E()}))}function at(t){let e=!1,n=()=>{e||(e=!0,t())};for(let o of ot)window.addEventListener(o,n,{once:!0,passive:!0});document.readyState==="complete"?setTimeout(n,F):window.addEventListener("load",()=>setTimeout(n,F),{once:!0})}function st(){let t=()=>{window.scrollY>it&&(window.removeEventListener("scroll",t),E())};window.addEventListener("scroll",t,{passive:!0}),setTimeout(E,rt)}function E(){s.clarityLoaded||!s.clarityId||(s.clarityLoaded=!0,window.clarity=window.clarity||function(){window.clarity.q=window.clarity.q||[],window.clarity.q.push(arguments)},S(`https://www.clarity.ms/tag/${encodeURIComponent(s.clarityId)}`,{crossorigin:"anonymous"}))}function S(t,e={}){let n=document.createElement("script");n.async=!0,n.src=t;for(let[o,r]of Object.entries(e))n.setAttribute(o,r);document.head.appendChild(n)}return P(ct);})();
|
package/package.json
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/analytics",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./site": "./src/site.js",
|
|
10
|
+
"./dist/webority-analytics-site.global.js": "./dist/webority-analytics-site.global.js"
|
|
9
11
|
},
|
|
10
12
|
"files": [
|
|
11
|
-
"src"
|
|
13
|
+
"src",
|
|
14
|
+
"dist"
|
|
12
15
|
],
|
|
13
16
|
"scripts": {
|
|
17
|
+
"build": "esbuild src/site.js --bundle --format=iife --global-name=WeborityAnalytics --target=es2020 --minify --outfile=dist/webority-analytics-site.global.js",
|
|
18
|
+
"prepack": "npm run build",
|
|
14
19
|
"lint": "biome lint ./src ./test",
|
|
15
20
|
"format": "biome format --write ./src ./test",
|
|
16
21
|
"check": "biome check ./src ./test",
|
|
17
|
-
"test": "node test/smoke.mjs"
|
|
22
|
+
"test": "node test/smoke.mjs && node test/site.mjs && npm run build --silent && node test/bundle.mjs"
|
|
18
23
|
},
|
|
19
|
-
"
|
|
20
|
-
"posthog-js": "1.417.1"
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"posthog-js": "^1.417.1"
|
|
21
26
|
},
|
|
22
27
|
"devDependencies": {
|
|
23
|
-
"@biomejs/biome": "2.5.8"
|
|
28
|
+
"@biomejs/biome": "2.5.8",
|
|
29
|
+
"esbuild": "0.28.2",
|
|
30
|
+
"posthog-js": "1.417.1"
|
|
24
31
|
}
|
|
25
32
|
}
|
package/src/analytics.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
import { isConsentInitialized, onConsentChange } from "./consent.js";
|
|
1
2
|
import { ensurePosthogInitialized } from "./posthog-bridge.js";
|
|
2
|
-
import { hasConsent, isActive, isEligible, resetState, state } from "./state.js";
|
|
3
|
+
import { hasConsent, isActive, isEligible, resetState, state, WARNED_EVENT_LIMIT } from "./state.js";
|
|
3
4
|
import { EVENT_NAME_PATTERN } from "./vocabulary.js";
|
|
4
5
|
|
|
6
|
+
let stopFollowingConsent = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starts PostHog capture. initConsent must run first: capture follows the visitor's analytics
|
|
10
|
+
* consent and nothing else, so a surface with no consent source would capture under a choice
|
|
11
|
+
* nobody was offered.
|
|
12
|
+
*/
|
|
5
13
|
export function initAnalytics(config) {
|
|
14
|
+
if (!isConsentInitialized()) {
|
|
15
|
+
throw new Error("@webority/analytics: call initConsent before initAnalytics.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
stopFollowingConsent?.();
|
|
19
|
+
stopFollowingConsent = onConsentChange((consent) => followConsent(consent.analytics));
|
|
20
|
+
|
|
6
21
|
resetState({
|
|
7
22
|
host: config.host,
|
|
8
23
|
clientKey: config.clientKey,
|
|
@@ -10,7 +25,6 @@ export function initAnalytics(config) {
|
|
|
10
25
|
surface: config.surface,
|
|
11
26
|
environment: config.environment,
|
|
12
27
|
enabled: config.enabled ?? true,
|
|
13
|
-
getConsent: config.getConsent ?? null,
|
|
14
28
|
logger: config.logger ?? null,
|
|
15
29
|
// Off unless the product asks: replay records page content, so turning it on is the
|
|
16
30
|
// product's disclosed decision, never a package default.
|
|
@@ -35,9 +49,7 @@ function warmUp() {
|
|
|
35
49
|
});
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
state.consentOverride = granted;
|
|
40
|
-
|
|
52
|
+
function followConsent(granted) {
|
|
41
53
|
if (granted && isEligible()) {
|
|
42
54
|
// A prior revoke leaves an already-initialized instance opted out, so re-granting must undo it.
|
|
43
55
|
if (state.posthogInstance) {
|
|
@@ -110,11 +122,42 @@ export function registerEvents(names) {
|
|
|
110
122
|
|
|
111
123
|
export function trackEvent(name, props = {}) {
|
|
112
124
|
if (!state.registeredEvents.has(name)) {
|
|
113
|
-
|
|
125
|
+
warnUnregisteredOnce(name);
|
|
126
|
+
return;
|
|
114
127
|
}
|
|
115
128
|
capture(name, props);
|
|
116
129
|
}
|
|
117
130
|
|
|
131
|
+
// Parity with IAnalyticsClient.Track on the server half: the same developer mistake never
|
|
132
|
+
// throws there, it is logged and the event is dropped. A throw here runs inside the user's
|
|
133
|
+
// browser and can take out the handler that raised it, so the browser half must not be the
|
|
134
|
+
// harsher one. Once per name, because a misregistered event usually sits in a hot path.
|
|
135
|
+
function warnUnregisteredOnce(name) {
|
|
136
|
+
if (state.warnedUnregisteredEvents.has(name)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (state.warnedUnregisteredEvents.size < WARNED_EVENT_LIMIT) {
|
|
140
|
+
state.warnedUnregisteredEvents.add(name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const logger = state.config?.logger;
|
|
144
|
+
const message = `@webority/analytics: event "${name}" is not registered, so it was dropped. Call registerEvents() first.`;
|
|
145
|
+
if (typeof logger?.warn === "function") {
|
|
146
|
+
logger.warn(message);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (typeof logger?.info === "function") {
|
|
150
|
+
logger.info(message);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The no-console rule exists to stop debug logging, and passing no logger is the default.
|
|
155
|
+
// Routing the one diagnostic this package emits to a logger nobody passed would hide exactly
|
|
156
|
+
// the mistake the warning exists to surface.
|
|
157
|
+
// biome-ignore lint/suspicious/noConsole: the package's one diagnostic must be visible by default
|
|
158
|
+
console.warn(message);
|
|
159
|
+
}
|
|
160
|
+
|
|
118
161
|
function capture(name, props) {
|
|
119
162
|
if (!isActive()) {
|
|
120
163
|
logInactive(name, props);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { getConsent, isConsentInitialized, onConsentChange } from "./consent.js";
|
|
2
|
+
|
|
3
|
+
export const ATTRIBUTION_STORAGE_KEY = "webority_attribution";
|
|
4
|
+
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const MAX_LENGTH = 200;
|
|
6
|
+
const MAX_URL_LENGTH = 500;
|
|
7
|
+
|
|
8
|
+
// Field names match Webority.Analytics' MarketingTouch, so the server binds this JSON as it is.
|
|
9
|
+
const PARAM_TO_FIELD = Object.freeze({
|
|
10
|
+
utm_source: "utmSource",
|
|
11
|
+
utm_medium: "utmMedium",
|
|
12
|
+
utm_campaign: "utmCampaign",
|
|
13
|
+
utm_content: "utmContent",
|
|
14
|
+
utm_term: "utmTerm",
|
|
15
|
+
gclid: "gclid",
|
|
16
|
+
gbraid: "gbraid",
|
|
17
|
+
wbraid: "wbraid",
|
|
18
|
+
fbclid: "fbclid",
|
|
19
|
+
msclkid: "msclkid",
|
|
20
|
+
li_fat_id: "liFatId",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
let followingConsent = false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Records how the visitor arrived: campaign parameters, click ids and an outside referrer.
|
|
27
|
+
* Call on every page load, before a client-side router strips the query string. The first
|
|
28
|
+
* touch is kept for 90 days; the last touch is replaced by each later campaign or referral
|
|
29
|
+
* visit. A direct visit changes nothing. Stored in localStorage only with marketing consent,
|
|
30
|
+
* otherwise for this tab alone.
|
|
31
|
+
*/
|
|
32
|
+
export function captureAttribution() {
|
|
33
|
+
ensureConsentInitialized("captureAttribution");
|
|
34
|
+
followConsent();
|
|
35
|
+
|
|
36
|
+
const touch = touchFromLocation();
|
|
37
|
+
if (!touch) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const current = readFrom(activeStore());
|
|
42
|
+
writeTo(activeStore(), { firstTouch: current?.firstTouch ?? touch, lastTouch: touch });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** { firstTouch, lastTouch }, or null when no campaign or referral visit is on record. */
|
|
46
|
+
export function getAttribution() {
|
|
47
|
+
ensureConsentInitialized("getAttribution");
|
|
48
|
+
return readFrom(activeStore());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function touchFromLocation() {
|
|
52
|
+
const params = new URLSearchParams(window.location.search);
|
|
53
|
+
const touch = {};
|
|
54
|
+
for (const [param, field] of Object.entries(PARAM_TO_FIELD)) {
|
|
55
|
+
const value = clip(params.get(param), MAX_LENGTH);
|
|
56
|
+
if (value) {
|
|
57
|
+
touch[field] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A referrer query parameter carries the original referrer across a hop between our own
|
|
62
|
+
// domains (marketing site to portal), where document.referrer would name our own site.
|
|
63
|
+
const referrer = clip(params.get("referrer"), MAX_URL_LENGTH) || outsideReferrer();
|
|
64
|
+
if (referrer) {
|
|
65
|
+
touch.referrer = referrer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Object.keys(touch).length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The path only: a query string can carry personal data a visitor typed into a form.
|
|
73
|
+
touch.landingPage = clip(window.location.pathname, MAX_URL_LENGTH);
|
|
74
|
+
touch.capturedDateTimeUtc = new Date().toISOString();
|
|
75
|
+
return touch;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function outsideReferrer() {
|
|
79
|
+
const referrer = document.referrer;
|
|
80
|
+
if (!referrer) {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return new URL(referrer).hostname === window.location.hostname ? "" : clip(referrer, MAX_URL_LENGTH);
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Moves the record when consent changes: into localStorage when marketing is granted, back to
|
|
91
|
+
// this tab's storage when it is withdrawn, so a revoke leaves nothing behind for 90 days.
|
|
92
|
+
function followConsent() {
|
|
93
|
+
if (followingConsent) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
followingConsent = true;
|
|
97
|
+
|
|
98
|
+
onConsentChange((consent) => {
|
|
99
|
+
const from = consent.marketing ? sessionStore() : localStore();
|
|
100
|
+
const to = consent.marketing ? localStore() : sessionStore();
|
|
101
|
+
const record = readFrom(from);
|
|
102
|
+
removeFrom(from);
|
|
103
|
+
if (record) {
|
|
104
|
+
writeTo(to, record);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function activeStore() {
|
|
110
|
+
return getConsent().marketing ? localStore() : sessionStore();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Storage access throws in some private modes and sandboxed frames. Attribution is then lost for
|
|
114
|
+
// this visit, which is the whole cost: the page and the product carry on.
|
|
115
|
+
function localStore() {
|
|
116
|
+
try {
|
|
117
|
+
return window.localStorage;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sessionStore() {
|
|
124
|
+
try {
|
|
125
|
+
return window.sessionStorage;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readFrom(store) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = store?.getItem(ATTRIBUTION_STORAGE_KEY);
|
|
134
|
+
if (!raw) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const parsed = JSON.parse(raw);
|
|
138
|
+
const firstTouch = fresh(parsed?.firstTouch);
|
|
139
|
+
const lastTouch = fresh(parsed?.lastTouch);
|
|
140
|
+
if (!firstTouch && !lastTouch) {
|
|
141
|
+
store.removeItem(ATTRIBUTION_STORAGE_KEY);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return { firstTouch: firstTouch ?? lastTouch, lastTouch: lastTouch ?? firstTouch };
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeTo(store, record) {
|
|
151
|
+
try {
|
|
152
|
+
store?.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(record));
|
|
153
|
+
} catch {
|
|
154
|
+
// Quota or a blocked store: see localStore.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function removeFrom(store) {
|
|
159
|
+
try {
|
|
160
|
+
store?.removeItem(ATTRIBUTION_STORAGE_KEY);
|
|
161
|
+
} catch {
|
|
162
|
+
// See localStore.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fresh(touch) {
|
|
167
|
+
if (!touch || typeof touch !== "object") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const captured = Date.parse(touch.capturedDateTimeUtc);
|
|
171
|
+
return Number.isFinite(captured) && Date.now() - captured <= RETENTION_MS ? touch : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clip(value, maxLength) {
|
|
175
|
+
const trimmed = String(value ?? "").trim();
|
|
176
|
+
return trimmed ? trimmed.slice(0, maxLength) : "";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ensureConsentInitialized(caller) {
|
|
180
|
+
if (!isConsentInitialized()) {
|
|
181
|
+
throw new Error(`@webority/analytics: call initConsent before ${caller}.`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function resetAttributionForTests() {
|
|
186
|
+
followingConsent = false;
|
|
187
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The EEA, the UK and Switzerland: visitors here opt in before any non-essential cookie.
|
|
2
|
+
// Webority.Analytics' ConsentRegion carries the same list for server-rendered pages, and a test
|
|
3
|
+
// on that side holds the two in step.
|
|
4
|
+
export const OPT_IN_COUNTRIES = Object.freeze([
|
|
5
|
+
"AT",
|
|
6
|
+
"BE",
|
|
7
|
+
"BG",
|
|
8
|
+
"HR",
|
|
9
|
+
"CY",
|
|
10
|
+
"CZ",
|
|
11
|
+
"DK",
|
|
12
|
+
"EE",
|
|
13
|
+
"FI",
|
|
14
|
+
"FR",
|
|
15
|
+
"DE",
|
|
16
|
+
"GR",
|
|
17
|
+
"HU",
|
|
18
|
+
"IE",
|
|
19
|
+
"IT",
|
|
20
|
+
"LV",
|
|
21
|
+
"LT",
|
|
22
|
+
"LU",
|
|
23
|
+
"MT",
|
|
24
|
+
"NL",
|
|
25
|
+
"PL",
|
|
26
|
+
"PT",
|
|
27
|
+
"RO",
|
|
28
|
+
"SK",
|
|
29
|
+
"SI",
|
|
30
|
+
"ES",
|
|
31
|
+
"SE",
|
|
32
|
+
"IS",
|
|
33
|
+
"LI",
|
|
34
|
+
"NO",
|
|
35
|
+
"GB",
|
|
36
|
+
"CH",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// An unknown country opts in: guessing "granted" for a visitor who is really in the EEA is the
|
|
40
|
+
// failure with a legal cost. XX and T1 are Cloudflare's markers for an unplaced country and a Tor exit.
|
|
41
|
+
export function requiresOptIn(countryCode) {
|
|
42
|
+
const code = typeof countryCode === "string" ? countryCode.trim().toUpperCase() : "";
|
|
43
|
+
if (code.length !== 2 || code === "XX" || code === "T1") {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return OPT_IN_COUNTRIES.includes(code);
|
|
47
|
+
}
|
package/src/consent.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { OPT_IN_COUNTRIES } from "./consent-regions.js";
|
|
2
|
+
|
|
3
|
+
export const CONSENT_COOKIE_NAME = "webority_cookie_consent";
|
|
4
|
+
export const CONSENT_CHANGE_EVENT = "webority-consent-change";
|
|
5
|
+
const CONSENT_COOKIE_DAYS = 365;
|
|
6
|
+
|
|
7
|
+
const consentState = {
|
|
8
|
+
initialized: false,
|
|
9
|
+
requiresOptIn: true,
|
|
10
|
+
cookieDomain: null,
|
|
11
|
+
saved: null,
|
|
12
|
+
};
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sets Google Consent Mode's defaults and applies the visitor's saved choice. Call once, first,
|
|
17
|
+
* before initAnalytics, loadTags or captureAttribution. requiresOptIn comes from the host, which
|
|
18
|
+
* knows the visitor's country (requiresOptIn(countryCode)); guessing it in the browser misplaces
|
|
19
|
+
* VPN users. cookieDomain (".capnix.ai") shares one choice between the website and every portal
|
|
20
|
+
* subdomain; without it the choice stays on the current host.
|
|
21
|
+
*/
|
|
22
|
+
export function initConsent(options) {
|
|
23
|
+
if (typeof options?.requiresOptIn !== "boolean") {
|
|
24
|
+
throw new Error("@webority/analytics: initConsent needs requiresOptIn (true or false), decided from the visitor's country.");
|
|
25
|
+
}
|
|
26
|
+
if (options.cookieDomain !== undefined && (typeof options.cookieDomain !== "string" || !options.cookieDomain.trim())) {
|
|
27
|
+
throw new Error('@webority/analytics: initConsent cookieDomain must be a domain such as ".example.com", or left out.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
consentState.initialized = true;
|
|
31
|
+
consentState.requiresOptIn = options.requiresOptIn;
|
|
32
|
+
consentState.cookieDomain = options.cookieDomain?.trim() ?? null;
|
|
33
|
+
consentState.saved = readConsentCookie();
|
|
34
|
+
|
|
35
|
+
pushConsentDefaults();
|
|
36
|
+
if (consentState.saved) {
|
|
37
|
+
pushConsentUpdate(consentState.saved);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isConsentInitialized() {
|
|
42
|
+
return consentState.initialized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The visitor's current consent. Before they choose (decided: false) every optional category
|
|
47
|
+
* follows the region default: denied where opt-in is required, granted everywhere else.
|
|
48
|
+
*/
|
|
49
|
+
export function getConsent() {
|
|
50
|
+
ensureInitialized("getConsent");
|
|
51
|
+
|
|
52
|
+
const saved = consentState.saved;
|
|
53
|
+
if (saved) {
|
|
54
|
+
return { essential: true, analytics: saved.analytics, functional: saved.functional, marketing: saved.marketing, decided: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const granted = !consentState.requiresOptIn;
|
|
58
|
+
return { essential: true, analytics: granted, functional: granted, marketing: granted, decided: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Records the visitor's choice, tells Google, and notifies every listener. */
|
|
62
|
+
export function saveConsent(preferences) {
|
|
63
|
+
ensureInitialized("saveConsent");
|
|
64
|
+
|
|
65
|
+
const saved = {
|
|
66
|
+
essential: true,
|
|
67
|
+
analytics: preferences?.analytics === true,
|
|
68
|
+
functional: preferences?.functional === true,
|
|
69
|
+
marketing: preferences?.marketing === true,
|
|
70
|
+
// The cookie's existing field name: visitors already hold cookies in this shape.
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
writeConsentCookie(saved);
|
|
74
|
+
consentState.saved = saved;
|
|
75
|
+
|
|
76
|
+
pushConsentUpdate(saved);
|
|
77
|
+
notify(getConsent());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Calls listener with the new consent after every saveConsent. Returns an unsubscribe function. */
|
|
81
|
+
export function onConsentChange(listener) {
|
|
82
|
+
listeners.add(listener);
|
|
83
|
+
return () => listeners.delete(listener);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function notify(consent) {
|
|
87
|
+
for (const listener of listeners) {
|
|
88
|
+
try {
|
|
89
|
+
listener(consent);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// One broken listener must not stop the others or the save. Rethrown on its own
|
|
92
|
+
// turn so it still reaches the page's error reporting instead of vanishing.
|
|
93
|
+
queueMicrotask(() => {
|
|
94
|
+
throw error;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Pages without a module loader (plain Razor scripts) listen on window instead.
|
|
100
|
+
if (typeof window?.dispatchEvent === "function" && typeof CustomEvent === "function") {
|
|
101
|
+
window.dispatchEvent(new CustomEvent(CONSENT_CHANGE_EVENT, { detail: consent }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ensureInitialized(caller) {
|
|
106
|
+
if (!consentState.initialized) {
|
|
107
|
+
throw new Error(`@webority/analytics: call initConsent before ${caller}.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Consent Mode reads the real Arguments object; a plain array is ignored.
|
|
112
|
+
function gtag() {
|
|
113
|
+
window.dataLayer = window.dataLayer || [];
|
|
114
|
+
// biome-ignore lint/complexity/noArguments: Consent Mode reads the Arguments object, not an array.
|
|
115
|
+
window.dataLayer.push(arguments);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Defaults must reach dataLayer before the Tag Manager container loads, or its tags fire ungated.
|
|
119
|
+
function pushConsentDefaults() {
|
|
120
|
+
gtag("consent", "default", {
|
|
121
|
+
ad_storage: "denied",
|
|
122
|
+
ad_user_data: "denied",
|
|
123
|
+
ad_personalization: "denied",
|
|
124
|
+
analytics_storage: "denied",
|
|
125
|
+
functionality_storage: "denied",
|
|
126
|
+
personalization_storage: "denied",
|
|
127
|
+
security_storage: "granted",
|
|
128
|
+
wait_for_update: 500,
|
|
129
|
+
region: [...OPT_IN_COUNTRIES],
|
|
130
|
+
});
|
|
131
|
+
gtag("consent", "default", {
|
|
132
|
+
ad_storage: "granted",
|
|
133
|
+
ad_user_data: "granted",
|
|
134
|
+
ad_personalization: "granted",
|
|
135
|
+
analytics_storage: "granted",
|
|
136
|
+
functionality_storage: "granted",
|
|
137
|
+
personalization_storage: "granted",
|
|
138
|
+
security_storage: "granted",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pushConsentUpdate(saved) {
|
|
143
|
+
const state = (granted) => (granted ? "granted" : "denied");
|
|
144
|
+
gtag("consent", "update", {
|
|
145
|
+
analytics_storage: state(saved.analytics),
|
|
146
|
+
functionality_storage: state(saved.functional),
|
|
147
|
+
personalization_storage: state(saved.functional),
|
|
148
|
+
ad_storage: state(saved.marketing),
|
|
149
|
+
ad_user_data: state(saved.marketing),
|
|
150
|
+
ad_personalization: state(saved.marketing),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readConsentCookie() {
|
|
155
|
+
const prefix = `${CONSENT_COOKIE_NAME}=`;
|
|
156
|
+
const entry = (document.cookie || "")
|
|
157
|
+
.split(";")
|
|
158
|
+
.map((part) => part.trim())
|
|
159
|
+
.find((part) => part.startsWith(prefix));
|
|
160
|
+
if (!entry) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(decodeURIComponent(entry.slice(prefix.length)));
|
|
166
|
+
if (!parsed || typeof parsed !== "object") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
essential: true,
|
|
171
|
+
analytics: parsed.analytics === true,
|
|
172
|
+
functional: parsed.functional === true,
|
|
173
|
+
marketing: parsed.marketing === true,
|
|
174
|
+
timestamp: parsed.timestamp ?? null,
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
// A cookie we cannot read is treated as no choice made: the banner asks again.
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function writeConsentCookie(saved) {
|
|
183
|
+
const expires = new Date(Date.now() + CONSENT_COOKIE_DAYS * 24 * 60 * 60 * 1000).toUTCString();
|
|
184
|
+
const domain = consentState.cookieDomain ? `;domain=${consentState.cookieDomain}` : "";
|
|
185
|
+
const secure = window.location?.protocol === "https:" ? ";Secure" : "";
|
|
186
|
+
// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is missing from browsers our visitors use.
|
|
187
|
+
document.cookie = `${CONSENT_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(saved))};expires=${expires};path=/${domain};SameSite=Lax${secure}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resetConsentForTests() {
|
|
191
|
+
consentState.initialized = false;
|
|
192
|
+
consentState.requiresOptIn = true;
|
|
193
|
+
consentState.cookieDomain = null;
|
|
194
|
+
consentState.saved = null;
|
|
195
|
+
listeners.clear();
|
|
196
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
export { identify, initAnalytics, registerEvents, resetIdentity,
|
|
1
|
+
export { identify, initAnalytics, registerEvents, resetIdentity, trackEvent } from "./analytics.js";
|
|
2
|
+
export {
|
|
3
|
+
CONSENT_CHANGE_EVENT,
|
|
4
|
+
CONSENT_COOKIE_NAME,
|
|
5
|
+
captureAttribution,
|
|
6
|
+
getAttribution,
|
|
7
|
+
getConsent,
|
|
8
|
+
initConsent,
|
|
9
|
+
loadTags,
|
|
10
|
+
onConsentChange,
|
|
11
|
+
requiresOptIn,
|
|
12
|
+
saveConsent,
|
|
13
|
+
} from "./site.js";
|
|
2
14
|
export { track } from "./track-helpers.js";
|
|
3
15
|
export { events } from "./vocabulary.js";
|
package/src/posthog-bridge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hasConsent, state } from "./state.js";
|
|
2
2
|
|
|
3
3
|
// Dynamic import keeps posthog-js out of the module graph entirely for callers that
|
|
4
|
-
// never go active (e.g. Local environment)
|
|
4
|
+
// never go active (e.g. Local environment), because it assumes browser globals at init time.
|
|
5
5
|
export async function ensurePosthogInitialized() {
|
|
6
6
|
if (state.posthogInstance) {
|
|
7
7
|
return state.posthogInstance;
|
package/src/site.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public websites: consent, marketing tags and attribution, without PostHog. The main entry
|
|
2
|
+
// re-exports all of this for portals that also capture product events.
|
|
3
|
+
export { captureAttribution, getAttribution } from "./attribution.js";
|
|
4
|
+
export { CONSENT_CHANGE_EVENT, CONSENT_COOKIE_NAME, getConsent, initConsent, onConsentChange, saveConsent } from "./consent.js";
|
|
5
|
+
export { requiresOptIn } from "./consent-regions.js";
|
|
6
|
+
export { loadTags } from "./tags.js";
|
package/src/state.js
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
|
+
import { getConsent as getSiteConsent, isConsentInitialized } from "./consent.js";
|
|
1
2
|
import { events } from "./vocabulary.js";
|
|
2
3
|
|
|
3
4
|
export const state = {
|
|
4
5
|
config: null,
|
|
5
|
-
consentOverride: null,
|
|
6
6
|
posthogInstance: null,
|
|
7
7
|
posthogInitPromise: null,
|
|
8
8
|
registeredEvents: new Set(Object.values(events)),
|
|
9
|
+
warnedUnregisteredEvents: new Set(),
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
// The warning exists to reach a developer once. After this many distinct bad names the message
|
|
13
|
+
// has landed, and a product building names from data must not grow the set for the life of the tab.
|
|
14
|
+
export const WARNED_EVENT_LIMIT = 50;
|
|
15
|
+
|
|
11
16
|
// registeredEvents is deliberately not reset: an event name is part of the product's
|
|
12
17
|
// vocabulary, not of one analytics session, so a product that registers at module scope and
|
|
13
|
-
// starts analytics later in a bootstrap effect keeps its registrations.
|
|
18
|
+
// starts analytics later in a bootstrap effect keeps its registrations. warnedUnregisteredEvents
|
|
19
|
+
// follows it for the same reason: the warning is about the code, not about one session, so
|
|
20
|
+
// re-initializing must not make the same mistake shout again.
|
|
14
21
|
export function resetState(config) {
|
|
15
22
|
state.config = config;
|
|
16
|
-
state.consentOverride = null;
|
|
17
23
|
state.posthogInstance = null;
|
|
18
24
|
state.posthogInitPromise = null;
|
|
19
25
|
}
|
|
@@ -26,15 +32,9 @@ export function isEligible() {
|
|
|
26
32
|
return Boolean(config.enabled && config.clientKey && config.environment !== "Local");
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
// The visitor's analytics consent is the only thing that gates capture.
|
|
29
36
|
export function hasConsent() {
|
|
30
|
-
|
|
31
|
-
return state.consentOverride;
|
|
32
|
-
}
|
|
33
|
-
const getConsent = state.config?.getConsent;
|
|
34
|
-
if (typeof getConsent === "function") {
|
|
35
|
-
return Boolean(getConsent());
|
|
36
|
-
}
|
|
37
|
-
return true;
|
|
37
|
+
return isConsentInitialized() && getSiteConsent().analytics;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function isActive() {
|
package/src/tags.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getConsent, isConsentInitialized, onConsentChange } from "./consent.js";
|
|
2
|
+
|
|
3
|
+
const INTERACTION_EVENTS = ["scroll", "mousemove", "touchstart", "keydown", "pointerdown"];
|
|
4
|
+
const DEFER_AFTER_LOAD_MS = 1500;
|
|
5
|
+
const CLARITY_FALLBACK_MS = 3000;
|
|
6
|
+
const CLARITY_SCROLL_PX = 100;
|
|
7
|
+
|
|
8
|
+
const tagState = {
|
|
9
|
+
loaded: false,
|
|
10
|
+
clarityId: null,
|
|
11
|
+
clarityLoaded: false,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loads the marketing tags. Tag Manager waits for the first interaction, or 1.5s after load,
|
|
16
|
+
* so its download never competes with the page's largest paint; Consent Mode (set by
|
|
17
|
+
* initConsent) gates what its tags may do. Clarity records sessions, so it loads only with
|
|
18
|
+
* analytics consent, and a revoke takes effect from the next page. Ahrefs is cookieless and
|
|
19
|
+
* loads straight away. GA4 is deliberately absent: it runs inside the Tag Manager container,
|
|
20
|
+
* and loading it here as well counts every page view twice.
|
|
21
|
+
*/
|
|
22
|
+
export function loadTags(options = {}) {
|
|
23
|
+
if (!isConsentInitialized()) {
|
|
24
|
+
throw new Error("@webority/analytics: call initConsent before loadTags.");
|
|
25
|
+
}
|
|
26
|
+
if (tagState.loaded) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
tagState.loaded = true;
|
|
30
|
+
|
|
31
|
+
const { gtmId, clarityId, ahrefsKey } = options;
|
|
32
|
+
|
|
33
|
+
if (ahrefsKey) {
|
|
34
|
+
appendScript("https://analytics.ahrefs.com/analytics.js", { "data-key": ahrefsKey });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (gtmId) {
|
|
38
|
+
runAfterFirstInteraction(() => {
|
|
39
|
+
window.dataLayer = window.dataLayer || [];
|
|
40
|
+
window.dataLayer.push({ "gtm.start": Date.now(), event: "gtm.js" });
|
|
41
|
+
appendScript(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(gtmId)}`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (clarityId) {
|
|
46
|
+
tagState.clarityId = clarityId;
|
|
47
|
+
if (getConsent().analytics) {
|
|
48
|
+
scheduleClarity();
|
|
49
|
+
}
|
|
50
|
+
onConsentChange((consent) => {
|
|
51
|
+
if (consent.analytics) {
|
|
52
|
+
loadClarity();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runAfterFirstInteraction(callback) {
|
|
59
|
+
let ran = false;
|
|
60
|
+
const run = () => {
|
|
61
|
+
if (ran) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
ran = true;
|
|
65
|
+
callback();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const eventName of INTERACTION_EVENTS) {
|
|
69
|
+
window.addEventListener(eventName, run, { once: true, passive: true });
|
|
70
|
+
}
|
|
71
|
+
if (document.readyState === "complete") {
|
|
72
|
+
setTimeout(run, DEFER_AFTER_LOAD_MS);
|
|
73
|
+
} else {
|
|
74
|
+
window.addEventListener("load", () => setTimeout(run, DEFER_AFTER_LOAD_MS), { once: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scheduleClarity() {
|
|
79
|
+
const onScroll = () => {
|
|
80
|
+
if (window.scrollY > CLARITY_SCROLL_PX) {
|
|
81
|
+
window.removeEventListener("scroll", onScroll);
|
|
82
|
+
loadClarity();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
86
|
+
setTimeout(loadClarity, CLARITY_FALLBACK_MS);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadClarity() {
|
|
90
|
+
if (tagState.clarityLoaded || !tagState.clarityId) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
tagState.clarityLoaded = true;
|
|
94
|
+
|
|
95
|
+
// Clarity's own queue: calls made before its script arrives are replayed once it does.
|
|
96
|
+
window.clarity =
|
|
97
|
+
window.clarity ||
|
|
98
|
+
function clarityQueue() {
|
|
99
|
+
window.clarity.q = window.clarity.q || [];
|
|
100
|
+
// biome-ignore lint/complexity/noArguments: Clarity's stub queues the Arguments object, as its own snippet does.
|
|
101
|
+
window.clarity.q.push(arguments);
|
|
102
|
+
};
|
|
103
|
+
appendScript(`https://www.clarity.ms/tag/${encodeURIComponent(tagState.clarityId)}`, { crossorigin: "anonymous" });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function appendScript(src, attributes = {}) {
|
|
107
|
+
const script = document.createElement("script");
|
|
108
|
+
script.async = true;
|
|
109
|
+
script.src = src;
|
|
110
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
111
|
+
script.setAttribute(name, value);
|
|
112
|
+
}
|
|
113
|
+
document.head.appendChild(script);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resetTagsForTests() {
|
|
117
|
+
tagState.loaded = false;
|
|
118
|
+
tagState.clarityId = null;
|
|
119
|
+
tagState.clarityLoaded = false;
|
|
120
|
+
}
|