form-attribution 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -31
- package/dist/script.min.js +1 -0
- package/package.json +5 -4
- package/dist/script.js +0 -859
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Form Attribution
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A lightweight, zero-dependency script that automatically captures UTM parameters, ad click IDs, and referrer data and passes them into your forms as hidden fields.
|
|
4
|
+
|
|
5
|
+
[](LICENSE.md)
|
|
6
|
+
[](https://www.npmjs.com/package/form-attribution)
|
|
7
|
+
|
|
8
|
+
**[Try the Script Builder](https://form-attribution.flashbrew.digital/builder)** | **[View Documentation](https://form-attribution.flashbrew.digital/docs)**
|
|
4
9
|
|
|
5
10
|
## Features
|
|
6
11
|
|
|
@@ -17,18 +22,10 @@ Automatically capture and persist marketing attribution parameters (UTM tags, re
|
|
|
17
22
|
|
|
18
23
|
### CDN
|
|
19
24
|
|
|
20
|
-
*jsDelivr:*
|
|
21
|
-
|
|
22
25
|
```html
|
|
23
26
|
<script src="https://cdn.jsdelivr.net/npm/form-attribution@latest/dist/script.min.js"></script>
|
|
24
27
|
```
|
|
25
28
|
|
|
26
|
-
*unpkg:*
|
|
27
|
-
|
|
28
|
-
```html
|
|
29
|
-
<script src="https://unpkg.com/form-attribution@latest/dist/script.min.js"></script>
|
|
30
|
-
```
|
|
31
|
-
|
|
32
29
|
## Quick Start
|
|
33
30
|
|
|
34
31
|
Add the script to your HTML before the closing `</body>` tag:
|
|
@@ -61,17 +58,28 @@ That's it! The script will automatically:
|
|
|
61
58
|
|
|
62
59
|
| Parameter | Description |
|
|
63
60
|
|-----------|-------------|
|
|
64
|
-
| `landing_page` | First page URL visited
|
|
65
|
-
| `current_page` | Current page URL (
|
|
61
|
+
| `landing_page` | First page URL visited |
|
|
62
|
+
| `current_page` | Current page URL (when form is submitted) |
|
|
66
63
|
| `referrer_url` | Document referrer |
|
|
67
64
|
| `first_touch_timestamp` | ISO 8601 timestamp of first visit |
|
|
68
65
|
|
|
66
|
+
### Click ID Parameters (when `data-click-ids="true"`)
|
|
67
|
+
|
|
68
|
+
| Parameter | Platform |
|
|
69
|
+
|-----------|----------|
|
|
70
|
+
| `gclid` | Google Ads |
|
|
71
|
+
| `fbclid` | Meta Ads |
|
|
72
|
+
| `msclkid` | Microsoft Advertising |
|
|
73
|
+
| `ttclid` | TikTok Ads |
|
|
74
|
+
| `li_fat_id` | LinkedIn Ads |
|
|
75
|
+
| `twclid` | Twitter/X Ads |
|
|
76
|
+
|
|
69
77
|
## Configuration
|
|
70
78
|
|
|
71
79
|
Configure the script using `data-*` attributes on the script tag:
|
|
72
80
|
|
|
73
81
|
```html
|
|
74
|
-
<script src="/dist/script.js"
|
|
82
|
+
<script src="/dist/script.min.js"
|
|
75
83
|
data-storage="sessionStorage"
|
|
76
84
|
data-field-prefix="attr_"
|
|
77
85
|
data-extra-params="gclid,fbclid"
|
|
@@ -90,6 +98,8 @@ Configure the script using `data-*` attributes on the script tag:
|
|
|
90
98
|
| `data-exclude-forms` | `""` | CSS selector for forms to exclude from injection |
|
|
91
99
|
| `data-storage-key` | `form_attribution_data` | Custom key name for stored data |
|
|
92
100
|
| `data-debug` | `false` | Enable console logging |
|
|
101
|
+
| `data-privacy` | `true` | Set to `"false"` to disable GPC/DNT privacy signal detection |
|
|
102
|
+
| `data-click-ids` | `false` | Set to `"true"` to automatically capture ad platform click IDs |
|
|
93
103
|
|
|
94
104
|
### Cookie Options
|
|
95
105
|
|
|
@@ -104,18 +114,10 @@ When using `data-storage="cookie"`:
|
|
|
104
114
|
|
|
105
115
|
## Usage Examples
|
|
106
116
|
|
|
107
|
-
### Capture Google and Facebook Click IDs
|
|
108
|
-
|
|
109
|
-
```html
|
|
110
|
-
<script src="/dist/script.js"
|
|
111
|
-
data-extra-params="gclid,fbclid,msclkid">
|
|
112
|
-
</script>
|
|
113
|
-
```
|
|
114
|
-
|
|
115
117
|
### Use localStorage for Longer Persistence
|
|
116
118
|
|
|
117
119
|
```html
|
|
118
|
-
<script src="/dist/script.js"
|
|
120
|
+
<script src="/dist/script.min.js"
|
|
119
121
|
data-storage="localStorage">
|
|
120
122
|
</script>
|
|
121
123
|
```
|
|
@@ -123,7 +125,7 @@ When using `data-storage="cookie"`:
|
|
|
123
125
|
### Use Cookies for Cross-Subdomain Tracking
|
|
124
126
|
|
|
125
127
|
```html
|
|
126
|
-
<script src="/dist/script.js"
|
|
128
|
+
<script src="/dist/script.min.js"
|
|
127
129
|
data-storage="cookie"
|
|
128
130
|
data-cookie-domain=".example.com"
|
|
129
131
|
data-cookie-expires="90">
|
|
@@ -133,7 +135,7 @@ When using `data-storage="cookie"`:
|
|
|
133
135
|
### Exclude Specific Forms
|
|
134
136
|
|
|
135
137
|
```html
|
|
136
|
-
<script src="/dist/script.js"
|
|
138
|
+
<script src="/dist/script.min.js"
|
|
137
139
|
data-exclude-forms=".login-form, [data-no-attribution]">
|
|
138
140
|
</script>
|
|
139
141
|
```
|
|
@@ -141,16 +143,14 @@ When using `data-storage="cookie"`:
|
|
|
141
143
|
### Add Field Prefix for CRM Compatibility
|
|
142
144
|
|
|
143
145
|
```html
|
|
144
|
-
<script src="/dist/script.js"
|
|
146
|
+
<script src="/dist/script.min.js"
|
|
145
147
|
data-field-prefix="lead_">
|
|
146
148
|
</script>
|
|
147
149
|
```
|
|
148
150
|
|
|
149
|
-
This creates fields like `lead_utm_source`, `lead_utm_medium`, etc.
|
|
150
|
-
|
|
151
151
|
## Script Builder
|
|
152
152
|
|
|
153
|
-
Use the interactive [Script Builder](
|
|
153
|
+
Use the interactive [Script Builder](https://form-attribution.flashbrew.digital) tool to generate a configured script tag with a visual interface.
|
|
154
154
|
|
|
155
155
|
## Storage Fallback Chain
|
|
156
156
|
|
|
@@ -169,7 +169,7 @@ The script respects user privacy preferences:
|
|
|
169
169
|
- **Global Privacy Control (GPC)** - Disables tracking when `navigator.globalPrivacyControl` is true
|
|
170
170
|
- **Do Not Track (DNT)** - Disables tracking when DNT is enabled
|
|
171
171
|
|
|
172
|
-
When privacy signals are detected, no data is captured or stored.
|
|
172
|
+
When privacy signals are detected, no data is captured or stored. You can override this behavior by setting `data-privacy="false"` on the script tag.
|
|
173
173
|
|
|
174
174
|
## Injected Fields
|
|
175
175
|
|
|
@@ -222,6 +222,6 @@ The script uses standard browser APIs with graceful fallbacks:
|
|
|
222
222
|
|
|
223
223
|
[Apache-2.0](LICENSE.md)
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
---
|
|
226
226
|
|
|
227
|
-
[Ben Sabic](https://bensabic.ca)
|
|
227
|
+
Built by [Ben Sabic](https://bensabic.ca) | [GitHub](https://github.com/Flash-Brew-Digital/form-attribution)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const STORAGE_KEY_REGEX=/^[a-zA-Z0-9_-]+$/;const FIELD_PREFIX_REGEX=/^[a-zA-Z0-9_-]*$/;const COOKIE_PATH_INVALID_REGEX=/[;\s]/;const SELECTOR_VALID_REGEX=/^[a-zA-Z0-9._#[\]="':\s,>+~-]*$/;(()=>{const SCRIPT_ELEMENT=document.currentScript??[...document.scripts].reverse().find(s=>{const src=s.getAttribute("src")||"";return src.includes("cdn.jsdelivr.net/npm/form-attribution@")&&src.endsWith("/dist/script.min.js")})??null;const DEFAULT_PARAMS=["utm_source","utm_medium","utm_campaign","utm_term","utm_content","utm_id","ref"];const META_PARAMS=["landing_page","current_page","referrer_url","first_touch_timestamp"];const CLICK_ID_PARAMS=["gclid","fbclid","msclkid","ttclid","li_fat_id","twclid"];const VALID_STORAGE_TYPES=["sessionStorage","localStorage","cookie"];const VALID_SAMESITE_VALUES=["lax","strict","none"];const MAX_COOKIE_SIZE=4e3;const MAX_PARAM_LENGTH=500;const MAX_URL_LENGTH=2e3;const safeParse=data=>{const parsed=JSON.parse(data);if(parsed&&typeof parsed==="object"&&!Array.isArray(parsed)){const safe=Object.create(null);for(const key of Object.keys(parsed)){if(key!=="__proto__"&&key!=="constructor"&&key!=="prototype"){safe[key]=parsed[key]}}return safe}return parsed};const sanitizeValue=val=>String(val).replace(/[<>'"]/g,char=>{const entities={"<":"<",">":">","'":"'",'"':"""};return entities[char]});const validateStorageKey=key=>{const safeKey=String(key??"form_attribution_data").trim();return STORAGE_KEY_REGEX.test(safeKey)?safeKey:"form_attribution_data"};const validateFieldPrefix=prefix=>{const safePrefix=String(prefix??"").trim();return FIELD_PREFIX_REGEX.test(safePrefix)?safePrefix:""};const validateCookiePath=path=>{const safePath=String(path??"/").trim();return safePath.startsWith("/")&&!COOKIE_PATH_INVALID_REGEX.test(safePath)?safePath:"/"};const validateExcludeForms=selector=>{if(!selector){return""}const safe=String(selector??"").trim();if(!SELECTOR_VALID_REGEX.test(safe)){return""}return safe};const validateCookieDomain=domain=>{if(!domain){return undefined}const safeDomain=String(domain).trim().toLowerCase();if(!safeDomain){return undefined}const currentHost=window.location.hostname.toLowerCase();if(safeDomain===currentHost){return safeDomain}if(currentHost.endsWith(`.${safeDomain}`)){return safeDomain}return undefined};const parseExtraParams=value=>{if(!value){return[]}const safeValue=String(value??"").trim();return safeValue?safeValue.split(",").map(p=>p.trim()).filter(Boolean):[]};const parseStorageType=value=>{const raw=String(value??"sessionStorage").trim();return VALID_STORAGE_TYPES.includes(raw)?raw:"sessionStorage"};const parseCookieExpires=value=>{const raw=Number.parseInt(value??"30",10);return Number.isFinite(raw)&&raw>=0?raw:30};const parseSameSite=value=>{const raw=String(value??"lax").trim().toLowerCase();return VALID_SAMESITE_VALUES.includes(raw)?raw:"lax"};const getConfig=()=>{const dataset=SCRIPT_ELEMENT?.dataset??{};return{storage:parseStorageType(dataset.storage),cookieDomain:validateCookieDomain(dataset.cookieDomain),cookiePath:validateCookiePath(dataset.cookiePath),cookieExpires:parseCookieExpires(dataset.cookieExpires),cookieSameSite:parseSameSite(dataset.cookieSamesite),fieldPrefix:validateFieldPrefix(dataset.fieldPrefix),extraParams:parseExtraParams(dataset.extraParams),excludeForms:validateExcludeForms(dataset.excludeForms),debug:dataset.debug==="true",storageKey:validateStorageKey(dataset.storageKey),respectPrivacy:dataset.privacy!=="false",trackClickIds:dataset.clickIds==="true"}};const CONFIG=getConfig();const TRACKED_PARAMS=[...new Set([...DEFAULT_PARAMS,...CONFIG.trackClickIds?CLICK_ID_PARAMS:[],...CONFIG.extraParams])];const PARAMS_TO_INJECT=[...new Set([...TRACKED_PARAMS,...META_PARAMS])];const log=(...args)=>{if(CONFIG.debug){console.log("[FormAttribution]",...args)}};const parseUrlParams=url=>{try{const urlObj=new URL(url,window.location.origin);return Object.fromEntries(urlObj.searchParams.entries())}catch{return{}}};const isPrivacySignalEnabled=()=>{if(navigator.globalPrivacyControl===true){return true}const dnt=navigator.doNotTrack||window.doNotTrack;return dnt==="1"||dnt==="yes"};const createMemoryAdapter=()=>{const map=new Map;return{get(key){return Promise.resolve(map.has(key)?map.get(key):null)},set(key,value){map.set(key,value);return Promise.resolve(true)},remove(key){map.delete(key);return Promise.resolve(true)}}};const getUsableWebStorage=type=>{try{const storage=window[type];if(!storage){return null}const testKey="__form_attribution_test__";storage.setItem(testKey,testKey);storage.removeItem(testKey);return storage}catch{return null}};const getStorageCandidates=requested=>{if(requested==="localStorage"){return["localStorage","sessionStorage","cookie","memory"]}if(requested==="sessionStorage"){return["sessionStorage","cookie","memory"]}if(requested==="cookie"){return["cookie","memory"]}return["sessionStorage","cookie","memory"]};const tryCreateAdapter=candidate=>{if(candidate==="cookie"){log("Using cookie storage");return createCookieAdapter()}if(candidate==="memory"){log("Using in-memory storage");return createMemoryAdapter()}const storage=getUsableWebStorage(candidate);if(storage){log(`Using ${candidate} storage`);return createWebStorageAdapter(storage)}return null};const createStorageAdapter=type=>{const requested=String(type??"").trim();const candidates=getStorageCandidates(requested);for(const candidate of candidates){const adapter=tryCreateAdapter(candidate);if(adapter){return adapter}}log("Falling back to in-memory storage");return createMemoryAdapter()};const createWebStorageAdapter=storage=>({get(key){try{const data=storage.getItem(key);return Promise.resolve(data?safeParse(data):null)}catch(e){log("Storage get error:",e);return Promise.resolve(null)}},set(key,value){try{storage.setItem(key,JSON.stringify(value));return Promise.resolve(true)}catch(e){log("Storage set error:",e);return Promise.resolve(false)}},remove(key){try{storage.removeItem(key);return Promise.resolve(true)}catch(e){log("Storage remove error:",e);return Promise.resolve(false)}}});const createCookieAdapter=()=>{const fallback=createMemoryAdapter();let primaryWriteFailed=false;let cookieStoreApi=null;try{cookieStoreApi=window.cookieStore??null}catch{cookieStoreApi=null}const useCookieStore=Boolean(cookieStoreApi&&typeof cookieStoreApi.get==="function"&&typeof cookieStoreApi.set==="function"&&typeof cookieStoreApi.delete==="function");let forceLegacy=!useCookieStore;const getExpirationDate=()=>{const date=new Date;date.setDate(date.getDate()+CONFIG.cookieExpires);return date};const shouldUseSecure=CONFIG.cookieSameSite==="none"||window.location.protocol==="https:";const getSameSiteForCookieString=()=>{switch(CONFIG.cookieSameSite){case"strict":return"Strict";case"none":return"None";default:return"Lax"}};const legacyCookieAdapter={async get(key){try{if(primaryWriteFailed){return await fallback.get(key)}const cookies=document.cookie.split(";");for(const cookie of cookies){const[name,...valueParts]=cookie.trim().split("=");if(name===key){const value=valueParts.join("=");return safeParse(decodeURIComponent(value))}}return await fallback.get(key)}catch(e){log("Legacy cookie get error:",e);return await fallback.get(key)}},async set(key,value){try{const encodedValue=encodeURIComponent(JSON.stringify(value));const expires=getExpirationDate().toUTCString();let cookieStr=`${key}=${encodedValue}; path=${CONFIG.cookiePath}; expires=${expires}; SameSite=${getSameSiteForCookieString()}`;if(CONFIG.cookieDomain){cookieStr+=`; domain=${CONFIG.cookieDomain}`}if(shouldUseSecure){cookieStr+="; Secure"}if(cookieStr.length>MAX_COOKIE_SIZE){log(`Cookie size (${cookieStr.length}) exceeds limit (${MAX_COOKIE_SIZE}), falling back to memory storage`);primaryWriteFailed=true;await fallback.set(key,value);return true}document.cookie=cookieStr;await fallback.set(key,value);return true}catch(e){log("Legacy cookie set error:",e);primaryWriteFailed=true;await fallback.set(key,value);return true}},async remove(key){try{let cookieStr=`${key}=; path=${CONFIG.cookiePath}; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=${getSameSiteForCookieString()}`;if(CONFIG.cookieDomain){cookieStr+=`; domain=${CONFIG.cookieDomain}`}if(shouldUseSecure){cookieStr+="; Secure"}document.cookie=cookieStr;await fallback.remove(key);return true}catch(e){log("Legacy cookie remove error:",e);await fallback.remove(key);return true}}};const cookieStoreAdapter={async get(key){try{if(primaryWriteFailed){return fallback.get(key)}if(forceLegacy){return legacyCookieAdapter.get(key)}const cookie=await cookieStoreApi.get(key);if(cookie?.value){return safeParse(decodeURIComponent(cookie.value))}return fallback.get(key)}catch(e){log("CookieStore get error:",e);forceLegacy=true;return legacyCookieAdapter.get(key)}},async set(key,value){try{if(forceLegacy){return legacyCookieAdapter.set(key,value)}const encodedValue=encodeURIComponent(JSON.stringify(value));if(encodedValue.length>MAX_COOKIE_SIZE){log(`Cookie value size (${encodedValue.length}) exceeds limit (${MAX_COOKIE_SIZE}), falling back to memory storage`);primaryWriteFailed=true;await fallback.set(key,value);return true}const cookieOptions={name:key,value:encodedValue,path:CONFIG.cookiePath,expires:getExpirationDate(),sameSite:CONFIG.cookieSameSite,secure:shouldUseSecure};if(CONFIG.cookieDomain){cookieOptions.domain=CONFIG.cookieDomain}await cookieStoreApi.set(cookieOptions);await fallback.set(key,value);return true}catch(e){log("CookieStore set error:",e);forceLegacy=true;return legacyCookieAdapter.set(key,value)}},async remove(key){try{if(forceLegacy){return legacyCookieAdapter.remove(key)}const deleteOptions={name:key,path:CONFIG.cookiePath};if(CONFIG.cookieDomain){deleteOptions.domain=CONFIG.cookieDomain}await cookieStoreApi.delete(deleteOptions);await fallback.remove(key);return true}catch(e){log("CookieStore remove error:",e);forceLegacy=true;return legacyCookieAdapter.remove(key)}}};log(`Using ${useCookieStore?"CookieStore API":"legacy document.cookie"}`);return useCookieStore?cookieStoreAdapter:legacyCookieAdapter};const captureAttributionData=()=>{const currentUrl=window.location.href;const urlParams=parseUrlParams(currentUrl);const trackedParams=TRACKED_PARAMS;const attributionData={};for(const param of trackedParams){if(urlParams[param]!==undefined){attributionData[param]=String(urlParams[param]).substring(0,MAX_PARAM_LENGTH)}}attributionData.landing_page=currentUrl.split("?")[0].substring(0,MAX_URL_LENGTH);attributionData.referrer_url=(document.referrer||"").substring(0,MAX_URL_LENGTH);attributionData.first_touch_timestamp=(new Date).toISOString();return attributionData};const mergeAttributionData=(existing,current)=>{if(!existing){return current}const merged={...existing};const trackedParams=TRACKED_PARAMS;for(const param of trackedParams){if(current[param]!==undefined&&existing[param]===undefined){merged[param]=current[param]}}if(!merged.landing_page){merged.landing_page=current.landing_page}if(!merged.referrer_url&¤t.referrer_url){merged.referrer_url=current.referrer_url}if(!merged.first_touch_timestamp){merged.first_touch_timestamp=current.first_touch_timestamp}return merged};const shouldIncludeForm=form=>{if(!CONFIG.excludeForms){return true}try{return!form.matches(CONFIG.excludeForms)}catch{return true}};const getAttributionEntries=data=>{const entries=[];if(!data||typeof data!=="object"){return entries}for(const param of PARAMS_TO_INJECT){if(param==="current_page"){continue}const value=data[param];if(value!==undefined&&value!==null&&value!==""){entries.push({name:`${CONFIG.fieldPrefix}${param}`,value:String(value)})}}entries.push({name:`${CONFIG.fieldPrefix}current_page`,value:window.location.href.split("?")[0]});return entries};const getTargetForms=()=>Array.from(document.querySelectorAll("form")).filter(shouldIncludeForm);const removeExistingFields=form=>{const existingFields=form.querySelectorAll('input[data-form-attribution="true"]');for(const field of existingFields){field.remove()}};const clearManagedFieldValues=form=>{const managedFields=form.querySelectorAll('input[type="hidden"][data-form-attribution-managed="true"]');for(const field of managedFields){field.value=""}};const getFormElements=form=>{try{return Object.getOwnPropertyDescriptor(HTMLFormElement.prototype,"elements").get.call(form)}catch{return form.elements}};const getHiddenInputsByName=(form,name)=>{const matches=[];const elements=getFormElements(form);if(!elements){return matches}for(const el of elements){if(!el||el.tagName!=="INPUT"){continue}const input=el;if(input.type==="hidden"&&input.name===name){matches.push(input)}}return matches};const syncFormAttributionFields=(form,entries)=>{removeExistingFields(form);if(!entries||entries.length===0){clearManagedFieldValues(form);return}const fragment=document.createDocumentFragment();for(const entry of entries){const existingInputs=getHiddenInputsByName(form,entry.name);const safeValue=sanitizeValue(entry.value);if(existingInputs.length>0){for(const input of existingInputs){input.value=safeValue;input.dataset.formAttributionManaged="true"}continue}const input=document.createElement("input");input.type="hidden";input.name=entry.name;input.value=safeValue;input.dataset.formAttribution="true";input.dataset.formAttributionManaged="true";fragment.appendChild(input)}if(fragment.hasChildNodes()){form.appendChild(fragment)}};const injectIntoForms=data=>{const forms=getTargetForms();if(forms.length===0){log("No forms found on page");return}const entries=getAttributionEntries(data);for(const form of forms){syncFormAttributionFields(form,entries);log("Synced attribution fields in form:",form.id||form.name||"[unnamed]")}log(entries.length===0?`Cleared attribution fields in ${forms.length} form(s)`:`Injected attribution data into ${forms.length} form(s)`)};const setupFormObserver=getData=>{const pendingForms=new Set;let scheduled=false;const flush=()=>{scheduled=false;if(pendingForms.size===0){return}const entries=getAttributionEntries(getData());for(const form of pendingForms){if(document.contains(form)){syncFormAttributionFields(form,entries)}}pendingForms.clear()};const scheduleFlush=()=>{if(scheduled){return}scheduled=true;if(typeof queueMicrotask==="function"){queueMicrotask(flush)}else{Promise.resolve().then(flush)}};const addFormIfIncluded=form=>{if(shouldIncludeForm(form)){pendingForms.add(form)}};const collectFormsFromNode=node=>{if(node.tagName==="FORM"){addFormIfIncluded(node)}if(node.querySelectorAll){const nestedForms=node.querySelectorAll("form");for(const form of nestedForms){addFormIfIncluded(form)}}};const processAddedNodes=addedNodes=>{for(const node of addedNodes){if(node.nodeType!==Node.ELEMENT_NODE){continue}collectFormsFromNode(node)}};const handleMutations=mutations=>{for(const mutation of mutations){processAddedNodes(mutation.addedNodes)}if(pendingForms.size>0){scheduleFlush()}};const observer=new MutationObserver(handleMutations);if(!document.body){log("Form observer could not initialize: document.body not found");return observer}observer.observe(document.body,{childList:true,subtree:true});log("Form observer initialized");return observer};const init=async()=>{log("Initializing with config:",CONFIG);const storage=createStorageAdapter(CONFIG.storage);let latestData=null;const existingData=await storage.get(CONFIG.storageKey);log("Existing attribution data:",existingData);const currentData=captureAttributionData();log("Current attribution data:",currentData);const mergedData=mergeAttributionData(existingData,currentData);latestData=mergedData;log("Merged attribution data:",mergedData);if(Object.keys(mergedData).length>0){await storage.set(CONFIG.storageKey,mergedData);log("Attribution data saved")}injectIntoForms(latestData);setupFormObserver(()=>latestData);log("Initialization complete")};const run=async()=>{if(CONFIG.respectPrivacy&&isPrivacySignalEnabled()){log("Tracking disabled due to privacy signal (GPC/DNT)");return}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",()=>{init().catch(e=>log("Initialization error:",e))},{once:true})}else{await init().catch(e=>log("Initialization error:",e))}};run()})();
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form-attribution",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Automatically capture and persist marketing attribution data in your web forms.",
|
|
5
5
|
"homepage": "https://github.com/flash-brew-digital/form-attribution#readme",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"type": "module",
|
|
8
|
-
"main": "dist/script.js",
|
|
8
|
+
"main": "dist/script.min.js",
|
|
9
9
|
"files": [
|
|
10
|
-
"dist/script.js"
|
|
10
|
+
"dist/script.min.js"
|
|
11
11
|
],
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"url": "https://github.com/flash-brew-digital/form-attribution/issues"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"
|
|
34
|
+
"build": "mkdir -p dist && npx terser src/script.js -o dist/script.min.js",
|
|
35
|
+
"release": "pnpm build && npm publish --access public",
|
|
35
36
|
"check": "npx ultracite check",
|
|
36
37
|
"fix": "npx ultracite fix",
|
|
37
38
|
"test": "npx playwright test"
|
package/dist/script.js
DELETED
|
@@ -1,859 +0,0 @@
|
|
|
1
|
-
(() => {
|
|
2
|
-
const SCRIPT_ELEMENT =
|
|
3
|
-
document.currentScript ??
|
|
4
|
-
[...document.scripts]
|
|
5
|
-
.reverse()
|
|
6
|
-
.find((s) => {
|
|
7
|
-
const src = s.getAttribute("src") || "";
|
|
8
|
-
return (
|
|
9
|
-
src.includes("cdn.jsdelivr.net/npm/form-attribution@") &&
|
|
10
|
-
src.endsWith("/dist/script.min.js")
|
|
11
|
-
);
|
|
12
|
-
}) ??
|
|
13
|
-
null;
|
|
14
|
-
|
|
15
|
-
const DEFAULT_PARAMS = [
|
|
16
|
-
"utm_source",
|
|
17
|
-
"utm_medium",
|
|
18
|
-
"utm_campaign",
|
|
19
|
-
"utm_term",
|
|
20
|
-
"utm_content",
|
|
21
|
-
"utm_id",
|
|
22
|
-
"ref",
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
const META_PARAMS = [
|
|
26
|
-
"landing_page",
|
|
27
|
-
"current_page",
|
|
28
|
-
"referrer_url",
|
|
29
|
-
"first_touch_timestamp",
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
const VALID_STORAGE_TYPES = ["sessionStorage", "localStorage", "cookie"];
|
|
33
|
-
const VALID_SAMESITE_VALUES = ["lax", "strict", "none"];
|
|
34
|
-
const MAX_COOKIE_SIZE = 4000;
|
|
35
|
-
const MAX_PARAM_LENGTH = 500;
|
|
36
|
-
const MAX_URL_LENGTH = 2000;
|
|
37
|
-
|
|
38
|
-
const safeParse = (data) => {
|
|
39
|
-
const parsed = JSON.parse(data);
|
|
40
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
41
|
-
const safe = Object.create(null);
|
|
42
|
-
for (const key of Object.keys(parsed)) {
|
|
43
|
-
if (key !== "__proto__" && key !== "constructor" && key !== "prototype") {
|
|
44
|
-
safe[key] = parsed[key];
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return safe;
|
|
48
|
-
}
|
|
49
|
-
return parsed;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const sanitizeValue = (val) => {
|
|
53
|
-
return String(val).replace(/[<>'"]/g, (char) => {
|
|
54
|
-
const entities = { "<": "<", ">": ">", "'": "'", '"': """ };
|
|
55
|
-
return entities[char];
|
|
56
|
-
});
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const validateStorageKey = (key) => {
|
|
60
|
-
const safeKey = String(key ?? "form_attribution_data").trim();
|
|
61
|
-
return /^[a-zA-Z0-9_-]+$/.test(safeKey) ? safeKey : "form_attribution_data";
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const validateFieldPrefix = (prefix) => {
|
|
65
|
-
const safePrefix = String(prefix ?? "").trim();
|
|
66
|
-
return /^[a-zA-Z0-9_-]*$/.test(safePrefix) ? safePrefix : "";
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const validateCookiePath = (path) => {
|
|
70
|
-
const safePath = String(path ?? "/").trim();
|
|
71
|
-
return safePath.startsWith("/") && !/[;\s]/.test(safePath) ? safePath : "/";
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const validateExcludeForms = (selector) => {
|
|
75
|
-
if (!selector) {
|
|
76
|
-
return "";
|
|
77
|
-
}
|
|
78
|
-
const safe = String(selector ?? "").trim();
|
|
79
|
-
if (!/^[a-zA-Z0-9._#\[\]="':\s,>+~-]*$/.test(safe)) {
|
|
80
|
-
return "";
|
|
81
|
-
}
|
|
82
|
-
return safe;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const validateCookieDomain = (domain) => {
|
|
86
|
-
if (!domain) {
|
|
87
|
-
return undefined;
|
|
88
|
-
}
|
|
89
|
-
const safeDomain = String(domain).trim().toLowerCase();
|
|
90
|
-
if (!safeDomain) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
const currentHost = window.location.hostname.toLowerCase();
|
|
94
|
-
|
|
95
|
-
// Must be exact match or a parent domain of current host
|
|
96
|
-
if (safeDomain === currentHost) {
|
|
97
|
-
return safeDomain;
|
|
98
|
-
}
|
|
99
|
-
if (currentHost.endsWith("." + safeDomain)) {
|
|
100
|
-
return safeDomain;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return undefined;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const parseExtraParams = (value) => {
|
|
107
|
-
if (!value) {
|
|
108
|
-
return [];
|
|
109
|
-
}
|
|
110
|
-
const safeValue = String(value ?? "").trim();
|
|
111
|
-
return safeValue
|
|
112
|
-
? safeValue
|
|
113
|
-
.split(",")
|
|
114
|
-
.map((p) => p.trim())
|
|
115
|
-
.filter(Boolean)
|
|
116
|
-
: [];
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const parseStorageType = (value) => {
|
|
120
|
-
const raw = String(value ?? "sessionStorage").trim();
|
|
121
|
-
return VALID_STORAGE_TYPES.includes(raw) ? raw : "sessionStorage";
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const parseCookieExpires = (value) => {
|
|
125
|
-
const raw = Number.parseInt(value ?? "30", 10);
|
|
126
|
-
return Number.isFinite(raw) && raw >= 0 ? raw : 30;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const parseSameSite = (value) => {
|
|
130
|
-
const raw = String(value ?? "lax")
|
|
131
|
-
.trim()
|
|
132
|
-
.toLowerCase();
|
|
133
|
-
return VALID_SAMESITE_VALUES.includes(raw) ? raw : "lax";
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const getConfig = () => {
|
|
137
|
-
const dataset = SCRIPT_ELEMENT?.dataset ?? {};
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
storage: parseStorageType(dataset.storage),
|
|
141
|
-
cookieDomain: validateCookieDomain(dataset.cookieDomain),
|
|
142
|
-
cookiePath: validateCookiePath(dataset.cookiePath),
|
|
143
|
-
cookieExpires: parseCookieExpires(dataset.cookieExpires),
|
|
144
|
-
cookieSameSite: parseSameSite(dataset.cookieSamesite),
|
|
145
|
-
fieldPrefix: validateFieldPrefix(dataset.fieldPrefix),
|
|
146
|
-
extraParams: parseExtraParams(dataset.extraParams),
|
|
147
|
-
excludeForms: validateExcludeForms(dataset.excludeForms),
|
|
148
|
-
debug: dataset.debug === "true",
|
|
149
|
-
storageKey: validateStorageKey(dataset.storageKey),
|
|
150
|
-
};
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const CONFIG = getConfig();
|
|
154
|
-
const TRACKED_PARAMS = [
|
|
155
|
-
...new Set([...DEFAULT_PARAMS, ...CONFIG.extraParams]),
|
|
156
|
-
];
|
|
157
|
-
const PARAMS_TO_INJECT = [...new Set([...TRACKED_PARAMS, ...META_PARAMS])];
|
|
158
|
-
|
|
159
|
-
const log = (...args) => {
|
|
160
|
-
if (CONFIG.debug) {
|
|
161
|
-
console.log("[FormAttribution]", ...args);
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const parseUrlParams = (url) => {
|
|
166
|
-
try {
|
|
167
|
-
const urlObj = new URL(url, window.location.origin);
|
|
168
|
-
return Object.fromEntries(urlObj.searchParams.entries());
|
|
169
|
-
} catch {
|
|
170
|
-
return {};
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
const isPrivacySignalEnabled = () => {
|
|
175
|
-
if (navigator.globalPrivacyControl === true) {
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const dnt = navigator.doNotTrack || window.doNotTrack;
|
|
180
|
-
return dnt === "1" || dnt === "yes";
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const createMemoryAdapter = () => {
|
|
184
|
-
const map = new Map();
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
get(key) {
|
|
188
|
-
return Promise.resolve(map.has(key) ? map.get(key) : null);
|
|
189
|
-
},
|
|
190
|
-
|
|
191
|
-
set(key, value) {
|
|
192
|
-
map.set(key, value);
|
|
193
|
-
return Promise.resolve(true);
|
|
194
|
-
},
|
|
195
|
-
|
|
196
|
-
remove(key) {
|
|
197
|
-
map.delete(key);
|
|
198
|
-
return Promise.resolve(true);
|
|
199
|
-
},
|
|
200
|
-
};
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const getUsableWebStorage = (type) => {
|
|
204
|
-
try {
|
|
205
|
-
const storage = window[type];
|
|
206
|
-
if (!storage) {
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const testKey = "__form_attribution_test__";
|
|
211
|
-
storage.setItem(testKey, testKey);
|
|
212
|
-
storage.removeItem(testKey);
|
|
213
|
-
return storage;
|
|
214
|
-
} catch {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
const getStorageCandidates = (requested) => {
|
|
220
|
-
if (requested === "localStorage") {
|
|
221
|
-
return ["localStorage", "sessionStorage", "cookie", "memory"];
|
|
222
|
-
}
|
|
223
|
-
if (requested === "sessionStorage") {
|
|
224
|
-
return ["sessionStorage", "cookie", "memory"];
|
|
225
|
-
}
|
|
226
|
-
if (requested === "cookie") {
|
|
227
|
-
return ["cookie", "memory"];
|
|
228
|
-
}
|
|
229
|
-
return ["sessionStorage", "cookie", "memory"];
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const tryCreateAdapter = (candidate) => {
|
|
233
|
-
if (candidate === "cookie") {
|
|
234
|
-
log("Using cookie storage");
|
|
235
|
-
return createCookieAdapter();
|
|
236
|
-
}
|
|
237
|
-
if (candidate === "memory") {
|
|
238
|
-
log("Using in-memory storage");
|
|
239
|
-
return createMemoryAdapter();
|
|
240
|
-
}
|
|
241
|
-
const storage = getUsableWebStorage(candidate);
|
|
242
|
-
if (storage) {
|
|
243
|
-
log(`Using ${candidate} storage`);
|
|
244
|
-
return createWebStorageAdapter(storage);
|
|
245
|
-
}
|
|
246
|
-
return null;
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const createStorageAdapter = (type) => {
|
|
250
|
-
const requested = String(type ?? "").trim();
|
|
251
|
-
const candidates = getStorageCandidates(requested);
|
|
252
|
-
|
|
253
|
-
for (const candidate of candidates) {
|
|
254
|
-
const adapter = tryCreateAdapter(candidate);
|
|
255
|
-
if (adapter) {
|
|
256
|
-
return adapter;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
log("Falling back to in-memory storage");
|
|
261
|
-
return createMemoryAdapter();
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const createWebStorageAdapter = (storage) => ({
|
|
265
|
-
get(key) {
|
|
266
|
-
try {
|
|
267
|
-
const data = storage.getItem(key);
|
|
268
|
-
return Promise.resolve(data ? safeParse(data) : null);
|
|
269
|
-
} catch (e) {
|
|
270
|
-
log("Storage get error:", e);
|
|
271
|
-
return Promise.resolve(null);
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
|
|
275
|
-
set(key, value) {
|
|
276
|
-
try {
|
|
277
|
-
storage.setItem(key, JSON.stringify(value));
|
|
278
|
-
return Promise.resolve(true);
|
|
279
|
-
} catch (e) {
|
|
280
|
-
log("Storage set error:", e);
|
|
281
|
-
return Promise.resolve(false);
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
|
|
285
|
-
remove(key) {
|
|
286
|
-
try {
|
|
287
|
-
storage.removeItem(key);
|
|
288
|
-
return Promise.resolve(true);
|
|
289
|
-
} catch (e) {
|
|
290
|
-
log("Storage remove error:", e);
|
|
291
|
-
return Promise.resolve(false);
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
const createCookieAdapter = () => {
|
|
297
|
-
const fallback = createMemoryAdapter();
|
|
298
|
-
let primaryWriteFailed = false;
|
|
299
|
-
let cookieStoreApi = null;
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
cookieStoreApi = window.cookieStore ?? null;
|
|
303
|
-
} catch {
|
|
304
|
-
cookieStoreApi = null;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const useCookieStore = Boolean(
|
|
308
|
-
cookieStoreApi &&
|
|
309
|
-
typeof cookieStoreApi.get === "function" &&
|
|
310
|
-
typeof cookieStoreApi.set === "function" &&
|
|
311
|
-
typeof cookieStoreApi.delete === "function"
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
let forceLegacy = !useCookieStore;
|
|
315
|
-
|
|
316
|
-
const getExpirationDate = () => {
|
|
317
|
-
const date = new Date();
|
|
318
|
-
date.setDate(date.getDate() + CONFIG.cookieExpires);
|
|
319
|
-
return date;
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
const shouldUseSecure =
|
|
323
|
-
CONFIG.cookieSameSite === "none" || window.location.protocol === "https:";
|
|
324
|
-
|
|
325
|
-
const getSameSiteForCookieString = () => {
|
|
326
|
-
switch (CONFIG.cookieSameSite) {
|
|
327
|
-
case "strict":
|
|
328
|
-
return "Strict";
|
|
329
|
-
case "none":
|
|
330
|
-
return "None";
|
|
331
|
-
default:
|
|
332
|
-
return "Lax";
|
|
333
|
-
}
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
const legacyCookieAdapter = {
|
|
337
|
-
async get(key) {
|
|
338
|
-
try {
|
|
339
|
-
if (primaryWriteFailed) {
|
|
340
|
-
return await fallback.get(key);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const cookies = document.cookie.split(";");
|
|
344
|
-
for (const cookie of cookies) {
|
|
345
|
-
const [name, ...valueParts] = cookie.trim().split("=");
|
|
346
|
-
if (name === key) {
|
|
347
|
-
const value = valueParts.join("=");
|
|
348
|
-
return safeParse(decodeURIComponent(value));
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return await fallback.get(key);
|
|
353
|
-
} catch (e) {
|
|
354
|
-
log("Legacy cookie get error:", e);
|
|
355
|
-
return await fallback.get(key);
|
|
356
|
-
}
|
|
357
|
-
},
|
|
358
|
-
|
|
359
|
-
async set(key, value) {
|
|
360
|
-
try {
|
|
361
|
-
const encodedValue = encodeURIComponent(JSON.stringify(value));
|
|
362
|
-
const expires = getExpirationDate().toUTCString();
|
|
363
|
-
|
|
364
|
-
let cookieStr = `${key}=${encodedValue}; path=${CONFIG.cookiePath}; expires=${expires}; SameSite=${getSameSiteForCookieString()}`;
|
|
365
|
-
|
|
366
|
-
if (CONFIG.cookieDomain) {
|
|
367
|
-
cookieStr += `; domain=${CONFIG.cookieDomain}`;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (shouldUseSecure) {
|
|
371
|
-
cookieStr += "; Secure";
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (cookieStr.length > MAX_COOKIE_SIZE) {
|
|
375
|
-
log(
|
|
376
|
-
`Cookie size (${cookieStr.length}) exceeds limit (${MAX_COOKIE_SIZE}), falling back to memory storage`
|
|
377
|
-
);
|
|
378
|
-
primaryWriteFailed = true;
|
|
379
|
-
await fallback.set(key, value);
|
|
380
|
-
return true;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// biome-ignore lint/suspicious/noDocumentCookie: Needed for legacy cookie fallback when CookieStore API isn't available.
|
|
384
|
-
document.cookie = cookieStr;
|
|
385
|
-
await fallback.set(key, value);
|
|
386
|
-
return true;
|
|
387
|
-
} catch (e) {
|
|
388
|
-
log("Legacy cookie set error:", e);
|
|
389
|
-
primaryWriteFailed = true;
|
|
390
|
-
await fallback.set(key, value);
|
|
391
|
-
return true;
|
|
392
|
-
}
|
|
393
|
-
},
|
|
394
|
-
|
|
395
|
-
async remove(key) {
|
|
396
|
-
try {
|
|
397
|
-
let cookieStr = `${key}=; path=${CONFIG.cookiePath}; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=${getSameSiteForCookieString()}`;
|
|
398
|
-
if (CONFIG.cookieDomain) {
|
|
399
|
-
cookieStr += `; domain=${CONFIG.cookieDomain}`;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (shouldUseSecure) {
|
|
403
|
-
cookieStr += "; Secure";
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// biome-ignore lint/suspicious/noDocumentCookie: Needed for legacy cookie fallback when CookieStore API isn't available.
|
|
407
|
-
document.cookie = cookieStr;
|
|
408
|
-
await fallback.remove(key);
|
|
409
|
-
return true;
|
|
410
|
-
} catch (e) {
|
|
411
|
-
log("Legacy cookie remove error:", e);
|
|
412
|
-
await fallback.remove(key);
|
|
413
|
-
return true;
|
|
414
|
-
}
|
|
415
|
-
},
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
const cookieStoreAdapter = {
|
|
419
|
-
async get(key) {
|
|
420
|
-
try {
|
|
421
|
-
if (primaryWriteFailed) {
|
|
422
|
-
return fallback.get(key);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (forceLegacy) {
|
|
426
|
-
return legacyCookieAdapter.get(key);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const cookie = await cookieStoreApi.get(key);
|
|
430
|
-
if (cookie?.value) {
|
|
431
|
-
return safeParse(decodeURIComponent(cookie.value));
|
|
432
|
-
}
|
|
433
|
-
return fallback.get(key);
|
|
434
|
-
} catch (e) {
|
|
435
|
-
log("CookieStore get error:", e);
|
|
436
|
-
forceLegacy = true;
|
|
437
|
-
return legacyCookieAdapter.get(key);
|
|
438
|
-
}
|
|
439
|
-
},
|
|
440
|
-
|
|
441
|
-
async set(key, value) {
|
|
442
|
-
try {
|
|
443
|
-
if (forceLegacy) {
|
|
444
|
-
return legacyCookieAdapter.set(key, value);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const encodedValue = encodeURIComponent(JSON.stringify(value));
|
|
448
|
-
|
|
449
|
-
if (encodedValue.length > MAX_COOKIE_SIZE) {
|
|
450
|
-
log(
|
|
451
|
-
`Cookie value size (${encodedValue.length}) exceeds limit (${MAX_COOKIE_SIZE}), falling back to memory storage`
|
|
452
|
-
);
|
|
453
|
-
primaryWriteFailed = true;
|
|
454
|
-
await fallback.set(key, value);
|
|
455
|
-
return true;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const cookieOptions = {
|
|
459
|
-
name: key,
|
|
460
|
-
value: encodedValue,
|
|
461
|
-
path: CONFIG.cookiePath,
|
|
462
|
-
expires: getExpirationDate(),
|
|
463
|
-
sameSite: CONFIG.cookieSameSite,
|
|
464
|
-
secure: shouldUseSecure,
|
|
465
|
-
};
|
|
466
|
-
|
|
467
|
-
if (CONFIG.cookieDomain) {
|
|
468
|
-
cookieOptions.domain = CONFIG.cookieDomain;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
await cookieStoreApi.set(cookieOptions);
|
|
472
|
-
await fallback.set(key, value);
|
|
473
|
-
return true;
|
|
474
|
-
} catch (e) {
|
|
475
|
-
log("CookieStore set error:", e);
|
|
476
|
-
forceLegacy = true;
|
|
477
|
-
return legacyCookieAdapter.set(key, value);
|
|
478
|
-
}
|
|
479
|
-
},
|
|
480
|
-
|
|
481
|
-
async remove(key) {
|
|
482
|
-
try {
|
|
483
|
-
if (forceLegacy) {
|
|
484
|
-
return legacyCookieAdapter.remove(key);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const deleteOptions = { name: key, path: CONFIG.cookiePath };
|
|
488
|
-
|
|
489
|
-
if (CONFIG.cookieDomain) {
|
|
490
|
-
deleteOptions.domain = CONFIG.cookieDomain;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
await cookieStoreApi.delete(deleteOptions);
|
|
494
|
-
await fallback.remove(key);
|
|
495
|
-
return true;
|
|
496
|
-
} catch (e) {
|
|
497
|
-
log("CookieStore remove error:", e);
|
|
498
|
-
forceLegacy = true;
|
|
499
|
-
return legacyCookieAdapter.remove(key);
|
|
500
|
-
}
|
|
501
|
-
},
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
log(
|
|
505
|
-
`Using ${useCookieStore ? "CookieStore API" : "legacy document.cookie"}`
|
|
506
|
-
);
|
|
507
|
-
return useCookieStore ? cookieStoreAdapter : legacyCookieAdapter;
|
|
508
|
-
};
|
|
509
|
-
|
|
510
|
-
const captureAttributionData = () => {
|
|
511
|
-
const currentUrl = window.location.href;
|
|
512
|
-
const urlParams = parseUrlParams(currentUrl);
|
|
513
|
-
const trackedParams = TRACKED_PARAMS;
|
|
514
|
-
|
|
515
|
-
const attributionData = {};
|
|
516
|
-
|
|
517
|
-
for (const param of trackedParams) {
|
|
518
|
-
if (urlParams[param] !== undefined) {
|
|
519
|
-
attributionData[param] = String(urlParams[param]).substring(
|
|
520
|
-
0,
|
|
521
|
-
MAX_PARAM_LENGTH
|
|
522
|
-
);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
attributionData.landing_page = currentUrl
|
|
527
|
-
.split("?")[0]
|
|
528
|
-
.substring(0, MAX_URL_LENGTH);
|
|
529
|
-
attributionData.referrer_url = (document.referrer || "").substring(
|
|
530
|
-
0,
|
|
531
|
-
MAX_URL_LENGTH
|
|
532
|
-
);
|
|
533
|
-
attributionData.first_touch_timestamp = new Date().toISOString();
|
|
534
|
-
|
|
535
|
-
return attributionData;
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
const mergeAttributionData = (existing, current) => {
|
|
539
|
-
if (!existing) {
|
|
540
|
-
return current;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const merged = { ...existing };
|
|
544
|
-
const trackedParams = TRACKED_PARAMS;
|
|
545
|
-
|
|
546
|
-
for (const param of trackedParams) {
|
|
547
|
-
if (current[param] !== undefined && existing[param] === undefined) {
|
|
548
|
-
merged[param] = current[param];
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (!merged.landing_page) {
|
|
553
|
-
merged.landing_page = current.landing_page;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (!merged.referrer_url && current.referrer_url) {
|
|
557
|
-
merged.referrer_url = current.referrer_url;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
if (!merged.first_touch_timestamp) {
|
|
561
|
-
merged.first_touch_timestamp = current.first_touch_timestamp;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return merged;
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
const shouldIncludeForm = (form) => {
|
|
568
|
-
if (!CONFIG.excludeForms) {
|
|
569
|
-
return true;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
return !form.matches(CONFIG.excludeForms);
|
|
574
|
-
} catch {
|
|
575
|
-
return true;
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
const getAttributionEntries = (data) => {
|
|
580
|
-
const entries = [];
|
|
581
|
-
|
|
582
|
-
if (!data || typeof data !== "object") {
|
|
583
|
-
return entries;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
for (const param of PARAMS_TO_INJECT) {
|
|
587
|
-
if (param === "current_page") {
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
const value = data[param];
|
|
591
|
-
if (value !== undefined && value !== null && value !== "") {
|
|
592
|
-
entries.push({
|
|
593
|
-
name: `${CONFIG.fieldPrefix}${param}`,
|
|
594
|
-
value: String(value),
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
entries.push({
|
|
600
|
-
name: `${CONFIG.fieldPrefix}current_page`,
|
|
601
|
-
value: window.location.href.split("?")[0],
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
return entries;
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
const getTargetForms = () =>
|
|
608
|
-
Array.from(document.querySelectorAll("form")).filter(shouldIncludeForm);
|
|
609
|
-
|
|
610
|
-
const removeExistingFields = (form) => {
|
|
611
|
-
const existingFields = form.querySelectorAll(
|
|
612
|
-
'input[data-form-attribution="true"]'
|
|
613
|
-
);
|
|
614
|
-
|
|
615
|
-
for (const field of existingFields) {
|
|
616
|
-
field.remove();
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
const clearManagedFieldValues = (form) => {
|
|
621
|
-
const managedFields = form.querySelectorAll(
|
|
622
|
-
'input[type="hidden"][data-form-attribution-managed="true"]'
|
|
623
|
-
);
|
|
624
|
-
for (const field of managedFields) {
|
|
625
|
-
field.value = "";
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
|
|
629
|
-
const getFormElements = (form) => {
|
|
630
|
-
try {
|
|
631
|
-
return Object.getOwnPropertyDescriptor(
|
|
632
|
-
HTMLFormElement.prototype,
|
|
633
|
-
"elements"
|
|
634
|
-
).get.call(form);
|
|
635
|
-
} catch {
|
|
636
|
-
return form.elements;
|
|
637
|
-
}
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
const getHiddenInputsByName = (form, name) => {
|
|
641
|
-
const matches = [];
|
|
642
|
-
const elements = getFormElements(form);
|
|
643
|
-
if (!elements) {
|
|
644
|
-
return matches;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
for (const el of elements) {
|
|
648
|
-
if (!el || el.tagName !== "INPUT") {
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const input = el;
|
|
653
|
-
if (input.type === "hidden" && input.name === name) {
|
|
654
|
-
matches.push(input);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
return matches;
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
const syncFormAttributionFields = (form, entries) => {
|
|
662
|
-
removeExistingFields(form);
|
|
663
|
-
|
|
664
|
-
if (!entries || entries.length === 0) {
|
|
665
|
-
clearManagedFieldValues(form);
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const fragment = document.createDocumentFragment();
|
|
670
|
-
|
|
671
|
-
for (const entry of entries) {
|
|
672
|
-
const existingInputs = getHiddenInputsByName(form, entry.name);
|
|
673
|
-
const safeValue = sanitizeValue(entry.value);
|
|
674
|
-
|
|
675
|
-
if (existingInputs.length > 0) {
|
|
676
|
-
for (const input of existingInputs) {
|
|
677
|
-
input.value = safeValue;
|
|
678
|
-
input.dataset.formAttributionManaged = "true";
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
continue;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const input = document.createElement("input");
|
|
685
|
-
input.type = "hidden";
|
|
686
|
-
input.name = entry.name;
|
|
687
|
-
input.value = safeValue;
|
|
688
|
-
|
|
689
|
-
input.dataset.formAttribution = "true";
|
|
690
|
-
input.dataset.formAttributionManaged = "true";
|
|
691
|
-
fragment.appendChild(input);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (fragment.hasChildNodes()) {
|
|
695
|
-
form.appendChild(fragment);
|
|
696
|
-
}
|
|
697
|
-
};
|
|
698
|
-
|
|
699
|
-
const injectIntoForms = (data) => {
|
|
700
|
-
const forms = getTargetForms();
|
|
701
|
-
|
|
702
|
-
if (forms.length === 0) {
|
|
703
|
-
log("No forms found on page");
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const entries = getAttributionEntries(data);
|
|
708
|
-
|
|
709
|
-
for (const form of forms) {
|
|
710
|
-
syncFormAttributionFields(form, entries);
|
|
711
|
-
|
|
712
|
-
log(
|
|
713
|
-
"Synced attribution fields in form:",
|
|
714
|
-
form.id || form.name || "[unnamed]"
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
log(
|
|
719
|
-
entries.length === 0
|
|
720
|
-
? `Cleared attribution fields in ${forms.length} form(s)`
|
|
721
|
-
: `Injected attribution data into ${forms.length} form(s)`
|
|
722
|
-
);
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
const setupFormObserver = (getData) => {
|
|
726
|
-
const pendingForms = new Set();
|
|
727
|
-
let scheduled = false;
|
|
728
|
-
|
|
729
|
-
const flush = () => {
|
|
730
|
-
scheduled = false;
|
|
731
|
-
if (pendingForms.size === 0) {
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const entries = getAttributionEntries(getData());
|
|
736
|
-
for (const form of pendingForms) {
|
|
737
|
-
if (document.contains(form)) {
|
|
738
|
-
syncFormAttributionFields(form, entries);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
pendingForms.clear();
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
const scheduleFlush = () => {
|
|
745
|
-
if (scheduled) {
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
scheduled = true;
|
|
749
|
-
|
|
750
|
-
if (typeof queueMicrotask === "function") {
|
|
751
|
-
queueMicrotask(flush);
|
|
752
|
-
} else {
|
|
753
|
-
Promise.resolve().then(flush);
|
|
754
|
-
}
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
const addFormIfIncluded = (form) => {
|
|
758
|
-
if (shouldIncludeForm(form)) {
|
|
759
|
-
pendingForms.add(form);
|
|
760
|
-
}
|
|
761
|
-
};
|
|
762
|
-
|
|
763
|
-
const collectFormsFromNode = (node) => {
|
|
764
|
-
if (node.tagName === "FORM") {
|
|
765
|
-
addFormIfIncluded(node);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
if (node.querySelectorAll) {
|
|
769
|
-
const nestedForms = node.querySelectorAll("form");
|
|
770
|
-
for (const form of nestedForms) {
|
|
771
|
-
addFormIfIncluded(form);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
};
|
|
775
|
-
|
|
776
|
-
const processAddedNodes = (addedNodes) => {
|
|
777
|
-
for (const node of addedNodes) {
|
|
778
|
-
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
collectFormsFromNode(node);
|
|
782
|
-
}
|
|
783
|
-
};
|
|
784
|
-
|
|
785
|
-
const handleMutations = (mutations) => {
|
|
786
|
-
for (const mutation of mutations) {
|
|
787
|
-
processAddedNodes(mutation.addedNodes);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if (pendingForms.size > 0) {
|
|
791
|
-
scheduleFlush();
|
|
792
|
-
}
|
|
793
|
-
};
|
|
794
|
-
|
|
795
|
-
const observer = new MutationObserver(handleMutations);
|
|
796
|
-
|
|
797
|
-
if (!document.body) {
|
|
798
|
-
log("Form observer could not initialize: document.body not found");
|
|
799
|
-
return observer;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
observer.observe(document.body, {
|
|
803
|
-
childList: true,
|
|
804
|
-
subtree: true,
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
log("Form observer initialized");
|
|
808
|
-
return observer;
|
|
809
|
-
};
|
|
810
|
-
|
|
811
|
-
const init = async () => {
|
|
812
|
-
log("Initializing with config:", CONFIG);
|
|
813
|
-
|
|
814
|
-
const storage = createStorageAdapter(CONFIG.storage);
|
|
815
|
-
let latestData = null;
|
|
816
|
-
|
|
817
|
-
const existingData = await storage.get(CONFIG.storageKey);
|
|
818
|
-
log("Existing attribution data:", existingData);
|
|
819
|
-
|
|
820
|
-
const currentData = captureAttributionData();
|
|
821
|
-
log("Current attribution data:", currentData);
|
|
822
|
-
|
|
823
|
-
const mergedData = mergeAttributionData(existingData, currentData);
|
|
824
|
-
latestData = mergedData;
|
|
825
|
-
log("Merged attribution data:", mergedData);
|
|
826
|
-
|
|
827
|
-
if (Object.keys(mergedData).length > 0) {
|
|
828
|
-
await storage.set(CONFIG.storageKey, mergedData);
|
|
829
|
-
log("Attribution data saved");
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
injectIntoForms(latestData);
|
|
833
|
-
|
|
834
|
-
setupFormObserver(() => latestData);
|
|
835
|
-
|
|
836
|
-
log("Initialization complete");
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
const run = async () => {
|
|
840
|
-
if (isPrivacySignalEnabled()) {
|
|
841
|
-
log("Tracking disabled due to privacy signal (GPC/DNT)");
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
if (document.readyState === "loading") {
|
|
846
|
-
document.addEventListener(
|
|
847
|
-
"DOMContentLoaded",
|
|
848
|
-
() => {
|
|
849
|
-
init().catch((e) => log("Initialization error:", e));
|
|
850
|
-
},
|
|
851
|
-
{ once: true }
|
|
852
|
-
);
|
|
853
|
-
} else {
|
|
854
|
-
await init().catch((e) => log("Initialization error:", e));
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
run();
|
|
859
|
-
})();
|