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 CHANGED
@@ -1,6 +1,11 @@
1
- # form-attribution
1
+ # Form Attribution
2
2
 
3
- Automatically capture and persist marketing attribution parameters (UTM tags, referrer data, landing pages) and inject them into HTML forms as hidden fields.
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](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE.md)
6
+ [![npm version](https://img.shields.io/npm/v/form-attribution.svg)](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 (without query string) |
65
- | `current_page` | Current page URL (without query string) |
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](script-builder/index.html) tool to generate a configured script tag with a visual interface.
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
- ## Author
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={"<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"};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&&current.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": "1.0.0",
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
- "release": "npm publish --access public",
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 = { "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" };
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
- })();