form-attribution 2.0.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,29 @@
1
1
  # Form Attribution
2
2
 
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.
3
+ A lightweight, zero-dependency script that automatically captures and passes the referrer, UTM parameters, ad click IDs, and more to your forms as hidden fields.
4
4
 
5
5
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE.md)
6
6
  [![npm version](https://img.shields.io/npm/v/form-attribution.svg)](https://www.npmjs.com/package/form-attribution)
7
7
 
8
- **[Try the Script Builder](https://form-attribution.flashbrew.digital/builder)** | **[View Documentation](https://form-attribution.flashbrew.digital/docs)**
8
+ **[Try the Script Builder](https://form-attribution.flashbrew.digital/builder?utm_source=github&utm_medium=referral&utm_campaign=readme)** |
9
+ **[View Documentation](https://form-attribution.flashbrew.digital/docs?utm_source=github&utm_medium=referral&utm_campaign=readme)**
9
10
 
10
11
  ## Features
11
12
 
12
- - **Zero dependencies** - Self-contained script using only browser APIs
13
- - **Automatic capture** - Captures UTM parameters, referrer, landing page, and timestamps
14
- - **Persistent storage** - Stores attribution data across page visits with smart fallbacks
15
- - **Form injection** - Injects hidden fields into all forms automatically
16
- - **Dynamic form support** - Detects and injects into dynamically added forms via MutationObserver
17
- - **First-touch attribution** - Preserves initial attribution data across subsequent visits
18
- - **Privacy-respecting** - Honors Global Privacy Control (GPC) and Do Not Track (DNT) signals
19
- - **XSS-safe** - Sanitizes all values before injection
20
-
21
- ## Installation
22
-
23
- ### CDN
24
-
25
- ```html
26
- <script src="https://cdn.jsdelivr.net/npm/form-attribution@latest/dist/script.min.js"></script>
27
- ```
13
+ - **Zero dependencies** - Runs entirely on native browser APIs with no external libraries
14
+ - **Automatic capture** - Records UTM parameters, referrer URL, landing page, timestamp and more without manual setup
15
+ - **Persistent storage** - Maintains attribution data across sessions using intelligent storage fallbacks
16
+ - **Form injection** - Automatically adds hidden fields to every form on the page
17
+ - **Dynamic form support** - Monitors the DOM via MutationObserver to handle forms added after page load
18
+ - **First-touch attribution** - Retains original attribution data even when users return later
19
+ - **Privacy-respecting** - Complies with Global Privacy Control (GPC) and Do Not Track (DNT) preferences
20
+ - **XSS-safe** - Sanitizes all injected values to prevent cross-site scripting attacks
21
+ - **Debug panel** - Visual overlay for inspecting attribution data, forms, and activity in real-time
22
+ - **JavaScript API** - Programmatic access via `window.FormAttribution` for custom integrations
28
23
 
29
24
  ## Quick Start
30
25
 
31
- Add the script to your HTML before the closing `</body>` tag:
26
+ Add the script to your website before the closing `</body>`tag:
32
27
 
33
28
  ```html
34
29
  <script src="https://cdn.jsdelivr.net/npm/form-attribution@latest/dist/script.min.js"></script>
@@ -36,8 +31,8 @@ Add the script to your HTML before the closing `</body>` tag:
36
31
 
37
32
  That's it! The script will automatically:
38
33
 
39
- 1. Capture UTM parameters from the URL
40
- 2. Store them in sessionStorage
34
+ 1. Capture common URL parameters and metadata (e.g. landing page)
35
+ 2. Store the data in the user's browser temporarily
41
36
  3. Inject hidden fields into all forms on the page
42
37
 
43
38
  ## Parameters Captured
@@ -59,7 +54,7 @@ That's it! The script will automatically:
59
54
  | Parameter | Description |
60
55
  |-----------|-------------|
61
56
  | `landing_page` | First page URL visited |
62
- | `current_page` | Current page URL (when form is submitted) |
57
+ | `current_page` | Current page URL (where form was submitted) |
63
58
  | `referrer_url` | Document referrer |
64
59
  | `first_touch_timestamp` | ISO 8601 timestamp of first visit |
65
60
 
@@ -76,11 +71,11 @@ That's it! The script will automatically:
76
71
 
77
72
  ## Configuration
78
73
 
79
- Configure the script using `data-*` attributes on the script tag:
74
+ Configure the script by adding optional data attributes to the script tag:
80
75
 
81
76
  ```html
82
77
  <script src="/dist/script.min.js"
83
- data-storage="sessionStorage"
78
+ data-storage="localStorage"
84
79
  data-field-prefix="attr_"
85
80
  data-extra-params="gclid,fbclid"
86
81
  data-exclude-forms=".no-track"
@@ -97,7 +92,7 @@ Configure the script using `data-*` attributes on the script tag:
97
92
  | `data-extra-params` | `""` | Comma-separated list of additional URL parameters to capture |
98
93
  | `data-exclude-forms` | `""` | CSS selector for forms to exclude from injection |
99
94
  | `data-storage-key` | `form_attribution_data` | Custom key name for stored data |
100
- | `data-debug` | `false` | Enable console logging |
95
+ | `data-debug` | `false` | Enable console logging and debug panel |
101
96
  | `data-privacy` | `true` | Set to `"false"` to disable GPC/DNT privacy signal detection |
102
97
  | `data-click-ids` | `false` | Set to `"true"` to automatically capture ad platform click IDs |
103
98
 
@@ -150,11 +145,11 @@ When using `data-storage="cookie"`:
150
145
 
151
146
  ## Script Builder
152
147
 
153
- Use the interactive [Script Builder](https://form-attribution.flashbrew.digital) tool to generate a configured script tag with a visual interface.
148
+ Use the interactive [Script Builder](https://form-attribution.flashbrew.digital/builder?utm_source=github&utm_medium=referral&utm_campaign=readme) tool to generate a configured script tag with a visual interface.
154
149
 
155
150
  ## Storage Fallback Chain
156
151
 
157
- The script uses intelligent fallback when storage is unavailable:
152
+ The script uses intelligent fallbacks when a storage type isn't available:
158
153
 
159
154
  | Requested | Fallback Chain |
160
155
  |-----------|----------------|
@@ -164,13 +159,81 @@ The script uses intelligent fallback when storage is unavailable:
164
159
 
165
160
  ## Privacy
166
161
 
167
- The script respects user privacy preferences:
162
+ By default, the script respects user privacy preferences:
168
163
 
169
164
  - **Global Privacy Control (GPC)** - Disables tracking when `navigator.globalPrivacyControl` is true
170
165
  - **Do Not Track (DNT)** - Disables tracking when DNT is enabled
171
166
 
172
167
  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
168
 
169
+ ## JavaScript API
170
+
171
+ Form Attribution exposes a global `FormAttribution` object for programmatic access:
172
+
173
+ ```javascript
174
+ // Get all attribution data
175
+ const data = FormAttribution.getData();
176
+
177
+ // Get a specific parameter
178
+ const source = FormAttribution.getParam('utm_source');
179
+
180
+ // Get tracked forms with their status
181
+ const forms = FormAttribution.getForms();
182
+
183
+ // Clear all stored data
184
+ FormAttribution.clear();
185
+
186
+ // Re-inject data into forms
187
+ FormAttribution.refresh();
188
+
189
+ // Register event callbacks (supports multiple listeners)
190
+ FormAttribution.on('onReady', ({ data, config }) => {
191
+ console.log('Attribution ready:', data);
192
+ });
193
+
194
+ // Remove a callback
195
+ FormAttribution.off('onCapture', myHandler);
196
+ ```
197
+
198
+ ### Available Methods
199
+
200
+ | Method | Returns | Description |
201
+ |--------|---------|-------------|
202
+ | `getData()` | `Object\|null` | Get all captured attribution data |
203
+ | `getParam(name)` | `string\|null` | Get a specific parameter value |
204
+ | `getForms()` | `Array` | Get list of forms with their status |
205
+ | `clear()` | `void` | Clear all stored attribution data |
206
+ | `refresh()` | `void` | Re-inject data into all forms |
207
+ | `on(event, cb)` | `Object` | Register event callback (chainable) |
208
+ | `off(event, cb)` | `Object` | Unregister a callback (chainable) |
209
+
210
+ ### Events
211
+
212
+ | Event | Payload | Description |
213
+ |-------|---------|-------------|
214
+ | `onReady` | `{ data, config }` | Fired when initialization is complete |
215
+ | `onCapture` | `{ data }` | Fired when new data is captured |
216
+ | `onUpdate` | `{ data }` | Fired when data is updated |
217
+
218
+ ## Debug Panel
219
+
220
+ Enable the debug panel by adding `data-debug="true"` to the script tag:
221
+
222
+ ```html
223
+ <script src="/dist/script.min.js" data-debug="true"></script>
224
+ ```
225
+
226
+ The debug panel provides:
227
+
228
+ - **Data Tab** - View all captured UTM parameters and metadata
229
+ - **Forms Tab** - See all forms and their injection status (click to highlight)
230
+ - **Log Tab** - Real-time activity log with timestamps
231
+ - **Actions** - Copy data to clipboard, clear storage, refresh forms
232
+
233
+ The panel is draggable, collapsible, and its state persists across page reloads. Uses Shadow DOM for style isolation.
234
+
235
+ > **Note:** Remove `data-debug` before deploying to production.
236
+
174
237
  ## Injected Fields
175
238
 
176
239
  Hidden fields are injected with the following attributes:
@@ -211,12 +274,16 @@ pnpm fix # Auto-fix lint issues
211
274
 
212
275
  ## Browser Support
213
276
 
214
- The script uses standard browser APIs with graceful fallbacks:
277
+ Built on standard browser APIs with graceful fallbacks for broad compatibility:
278
+
279
+ - **URL API** — Parses query parameters
280
+ - **MutationObserver** — Detects dynamically added forms
281
+ - **Web Storage API** — Persists data via sessionStorage and localStorage
282
+ - **CookieStore API** — Falls back to `document.cookie` for older
283
+
284
+ ## Documentation
215
285
 
216
- - URL API for query parameter parsing
217
- - MutationObserver for dynamic form detection
218
- - Web Storage API (sessionStorage/localStorage)
219
- - CookieStore API with legacy `document.cookie` fallback
286
+ Complete documentation is available at [https://form-attribution.flashbrew.digital/docs](https://form-attribution.flashbrew.digital/docs?utm_source=github&utm_medium=referral&utm_campaign=readme).
220
287
 
221
288
  ## License
222
289
 
@@ -224,4 +291,4 @@ The script uses standard browser APIs with graceful fallbacks:
224
291
 
225
292
  ---
226
293
 
227
- Built by [Ben Sabic](https://bensabic.ca) | [GitHub](https://github.com/Flash-Brew-Digital/form-attribution)
294
+ Built by [Ben Sabic](https://bensabic.ca?utm_source=github&utm_medium=referral&utm_campaign=readme) at [Flash Brew Digital](https://flashbrew.digital?utm_source=github&utm_medium=referral&utm_campaign=readme) | [GitHub](https://github.com/Flash-Brew-Digital/form-attribution)
@@ -0,0 +1 @@
1
+ (()=>{const FA=window.FormAttribution;if(!FA){console.warn("[FormAttribution Debug] FormAttribution API not found");return}const STYLES='/**\n * Form Attribution - Debug Overlay Styles\n * Injected into Shadow DOM for style isolation\n */\n\n:host {\n --fa-bg: light-dark(hsl(0 0% 100%), hsl(240 10% 3.9%));\n --fa-fg: light-dark(hsl(240 10% 3.9%), hsl(0 0% 98%));\n --fa-muted: light-dark(hsl(240 4.8% 95.9%), hsl(240 3.7% 15.9%));\n --fa-muted-fg: light-dark(hsl(240 3.8% 46.1%), hsl(240 5% 64.9%));\n --fa-border: light-dark(hsl(240 5.9% 90%), hsl(240 3.7% 15.9%));\n --fa-accent: light-dark(hsl(240 4.8% 95.9%), hsl(240 3.7% 15.9%));\n --fa-success: hsl(142 76% 36%);\n --fa-warning: hsl(38 92% 50%);\n --fa-destructive: hsl(0 84.2% 60.2%);\n --fa-radius: 0.5rem;\n --fa-shadow:\n 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n --fa-font: ui-sans-serif, system-ui, sans-serif;\n --fa-font-mono:\n ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;\n\n position: fixed;\n z-index: 999999;\n font-family: var(--fa-font);\n font-size: 13px;\n line-height: 1.4;\n color-scheme: light dark;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\n/* Panel */\n.panel {\n width: var(--fa-panel-width, 340px);\n max-width: calc(100vw - 32px);\n background: var(--fa-bg);\n border: 1px solid var(--fa-border);\n border-radius: var(--fa-radius);\n box-shadow: var(--fa-shadow);\n overflow: hidden;\n}\n\n.panel.hidden {\n display: none;\n}\n\n/* Header */\n.header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 12px;\n background: color-mix(in srgb, var(--fa-muted) 50%, transparent);\n border-bottom: 1px solid var(--fa-border);\n cursor: move;\n user-select: none;\n}\n\n.title {\n display: flex;\n align-items: center;\n gap: 8px;\n font-weight: 600;\n font-size: 13px;\n color: var(--fa-fg);\n}\n\n.title svg {\n width: 14px;\n height: 14px;\n color: var(--fa-muted-fg);\n}\n\n.controls {\n display: flex;\n gap: 4px;\n}\n\n.ctrl-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n background: transparent;\n border: none;\n border-radius: 4px;\n color: var(--fa-muted-fg);\n cursor: pointer;\n transition:\n background-color 0.15s,\n color 0.15s;\n}\n\n.ctrl-btn:hover {\n background: var(--fa-accent);\n color: var(--fa-fg);\n}\n\n.ctrl-btn svg {\n width: 14px;\n height: 14px;\n}\n\n/* Status Bar */\n.status {\n display: flex;\n flex-wrap: wrap;\n gap: 12px;\n padding: 10px 12px;\n background: color-mix(in srgb, var(--fa-muted) 25%, transparent);\n border-bottom: 1px solid var(--fa-border);\n font-size: 12px;\n}\n\n.status-item {\n display: flex;\n align-items: center;\n gap: 6px;\n color: var(--fa-muted-fg);\n}\n\n.status-dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--fa-success);\n box-shadow: 0 0 4px var(--fa-success);\n}\n\n.status-dot.inactive {\n background: var(--fa-muted-fg);\n box-shadow: none;\n}\n\n.status-dot.warning {\n background: var(--fa-warning);\n box-shadow: 0 0 4px var(--fa-warning);\n}\n\n.status-value {\n color: var(--fa-fg);\n font-weight: 500;\n}\n\n/* Tabs */\n.tabs {\n display: flex;\n border-bottom: 1px solid var(--fa-border);\n}\n\n.tab {\n flex: 1;\n padding: 8px;\n font-size: 12px;\n font-weight: 500;\n font-family: inherit;\n background: transparent;\n border: none;\n border-bottom: 2px solid transparent;\n color: var(--fa-muted-fg);\n cursor: pointer;\n transition:\n color 0.15s,\n border-color 0.15s,\n background-color 0.15s;\n}\n\n.tab:hover {\n color: var(--fa-fg);\n background: color-mix(in srgb, var(--fa-muted) 25%, transparent);\n}\n\n.tab.active {\n color: var(--fa-fg);\n border-bottom-color: var(--fa-fg);\n}\n\n.tab-content {\n display: none;\n}\n\n.tab-content.active {\n display: block;\n}\n\n/* Data Rows */\n.section-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 12px;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--fa-muted-fg);\n background: color-mix(in srgb, var(--fa-muted) 15%, transparent);\n}\n\n.badge {\n font-size: 10px;\n font-weight: 500;\n padding: 2px 6px;\n background: var(--fa-muted);\n color: var(--fa-fg);\n border-radius: 4px;\n text-transform: none;\n letter-spacing: normal;\n}\n\n.data-list {\n max-height: 200px;\n overflow-y: auto;\n}\n\n.data-list:nth-child(1 of .data-list) {\n max-height: 150px;\n}\n\n.data-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n padding: 8px 12px;\n border-bottom: 1px solid color-mix(in srgb, var(--fa-border) 50%, transparent);\n transition: background-color 0.15s;\n}\n\n.data-row:last-child {\n border-bottom: none;\n}\n\n.data-row:hover {\n background: color-mix(in srgb, var(--fa-muted) 25%, transparent);\n}\n\n.data-key {\n flex-shrink: 0;\n font-family: var(--fa-font-mono);\n font-size: 12px;\n color: var(--fa-muted-fg);\n}\n\n.data-value {\n flex: 1;\n font-family: var(--fa-font-mono);\n font-size: 12px;\n color: var(--fa-fg);\n text-align: right;\n word-break: break-all;\n max-width: 180px;\n}\n\n.data-value.empty {\n color: var(--fa-muted-fg);\n font-style: italic;\n}\n\n/* Forms List */\n.form-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 12px;\n border-bottom: 1px solid color-mix(in srgb, var(--fa-border) 50%, transparent);\n transition: background-color 0.15s;\n cursor: pointer;\n}\n\n.form-item:last-child {\n border-bottom: none;\n}\n\n.form-item:hover {\n background: color-mix(in srgb, var(--fa-muted) 25%, transparent);\n}\n\n.form-item.no-highlight {\n cursor: default;\n}\n\n.form-item.no-highlight:hover {\n background: transparent;\n}\n\n.form-item:active {\n background: color-mix(in srgb, var(--fa-muted) 40%, transparent);\n}\n\n.form-name {\n display: flex;\n align-items: center;\n gap: 6px;\n font-family: var(--fa-font-mono);\n font-size: 12px;\n color: var(--fa-fg);\n}\n\n.form-name svg {\n width: 12px;\n height: 12px;\n color: var(--fa-muted-fg);\n}\n\n.form-status {\n font-size: 11px;\n font-weight: 500;\n padding: 2px 6px;\n border-radius: 4px;\n}\n\n.form-status.injected {\n background: color-mix(in srgb, var(--fa-success) 15%, transparent);\n color: var(--fa-success);\n}\n\n.form-status.excluded {\n background: color-mix(in srgb, var(--fa-destructive) 15%, transparent);\n color: var(--fa-destructive);\n}\n\n.form-status.ready {\n background: var(--fa-muted);\n color: var(--fa-muted-fg);\n}\n\n/* Log */\n.log-list {\n max-height: 160px;\n overflow-y: auto;\n font-family: var(--fa-font-mono);\n font-size: 11px;\n}\n\n.log-entry {\n display: flex;\n gap: 8px;\n padding: 6px 12px;\n border-bottom: 1px solid color-mix(in srgb, var(--fa-border) 30%, transparent);\n}\n\n.log-entry:last-child {\n border-bottom: none;\n}\n\n.log-time {\n flex-shrink: 0;\n color: var(--fa-muted-fg);\n}\n\n.log-msg {\n color: var(--fa-fg);\n}\n\n.log-msg.success {\n color: var(--fa-success);\n}\n\n.log-msg.warning {\n color: var(--fa-warning);\n}\n\n.log-msg.error {\n color: var(--fa-destructive);\n}\n\n/* Actions */\n.actions {\n display: flex;\n justify-content: flex-end;\n gap: 6px;\n padding: 8px 12px;\n background: color-mix(in srgb, var(--fa-muted) 25%, transparent);\n border-top: 1px solid var(--fa-border);\n}\n\n.docs-link {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: auto;\n font-size: 12px;\n font-weight: 500;\n color: var(--fa-muted-fg);\n text-decoration: none;\n transition: color 0.15s;\n}\n\n.docs-link:hover {\n color: var(--fa-fg);\n}\n\n.docs-link svg {\n width: 14px;\n height: 14px;\n}\n\n.action-btn {\n display: grid;\n place-items: center;\n width: 28px;\n height: 28px;\n padding: 0;\n background: var(--fa-bg);\n border: 1px solid var(--fa-border);\n border-radius: 4px;\n color: var(--fa-fg);\n cursor: pointer;\n transition:\n background-color 0.15s,\n border-color 0.15s,\n color 0.15s;\n}\n\n.action-btn:hover {\n background: var(--fa-accent);\n}\n\n.action-btn.success {\n background: color-mix(in srgb, var(--fa-success) 15%, transparent);\n border-color: var(--fa-success);\n color: var(--fa-success);\n}\n\n.action-btn svg {\n grid-area: 1 / 1;\n width: 14px;\n height: 14px;\n transition: opacity 0.15s;\n}\n\n.action-btn .icon-check {\n opacity: 0;\n}\n\n.action-btn.success .icon-default {\n opacity: 0;\n}\n\n.action-btn.success .icon-check {\n opacity: 1;\n}\n\n/* Collapsed State */\n.collapsed-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--fa-collapsed-width, 40px);\n height: var(--fa-collapsed-width, 40px);\n background: var(--fa-bg);\n border: 1px solid var(--fa-border);\n border-radius: var(--fa-radius);\n box-shadow: var(--fa-shadow);\n cursor: pointer;\n transition: transform 0.15s;\n}\n\n.collapsed-btn.hidden {\n display: none;\n}\n\n.collapsed-btn:hover {\n transform: scale(1.05);\n}\n\n.collapsed-btn svg {\n width: 18px;\n height: 18px;\n color: var(--fa-fg);\n}\n\n.collapsed-badge {\n position: absolute;\n top: -4px;\n right: -4px;\n width: 14px;\n height: 14px;\n background: var(--fa-success);\n border: 2px solid var(--fa-bg);\n border-radius: 50%;\n}\n\n/* Empty State */\n.empty {\n padding: 24px;\n text-align: center;\n color: var(--fa-muted-fg);\n font-size: 13px;\n}\n\n.empty svg {\n width: 24px;\n height: 24px;\n margin: 0 auto 8px;\n opacity: 0.5;\n}\n\n/* Scrollbar */\n.data-list::-webkit-scrollbar,\n.log-list::-webkit-scrollbar {\n width: 6px;\n}\n\n.data-list::-webkit-scrollbar-track,\n.log-list::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.data-list::-webkit-scrollbar-thumb,\n.log-list::-webkit-scrollbar-thumb {\n background: var(--fa-border);\n border-radius: 3px;\n}\n\n.data-list::-webkit-scrollbar-thumb:hover,\n.log-list::-webkit-scrollbar-thumb:hover {\n background: var(--fa-muted-fg);\n}\n\n/* Responsive */\n@media (max-width: 400px) {\n .panel {\n max-width: calc(100vw - 16px);\n }\n}\n';const TEMPLATE='\x3c!-- Form Attribution - Debug Overlay Template --\x3e\n\n\x3c!-- Expanded Panel --\x3e\n<div class="panel" id="panel">\n <div class="header" id="drag-handle">\n <div class="title">\n <svg\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n >\n <path d="m18 16 4-4-4-4"/>\n <path d="m6 8-4 4 4 4"/>\n <path d="m14.5 4-5 16"/>\n </svg>\n Form Attribution\n </div>\n <div class="controls">\n <button class="ctrl-btn" id="btn-minimize" title="Minimize">\n <svg\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n >\n <path d="M5 12h14"/>\n </svg>\n </button>\n <button class="ctrl-btn" id="btn-close" title="Close">\n <svg\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n >\n <path d="M18 6 6 18"/>\n <path d="m6 6 12 12"/>\n </svg>\n </button>\n </div>\n </div>\n\n <div class="status" id="status-bar">\n \x3c!-- Populated dynamically --\x3e\n </div>\n\n <div class="tabs">\n <button class="tab active" data-tab="data">Data</button>\n <button class="tab" data-tab="forms">Forms</button>\n <button class="tab" data-tab="log">Log</button>\n </div>\n\n <div class="tab-content active" id="tab-data">\n \x3c!-- Populated dynamically --\x3e\n </div>\n\n <div class="tab-content" id="tab-forms">\n \x3c!-- Populated dynamically --\x3e\n </div>\n\n <div class="tab-content" id="tab-log">\n <div class="log-list" id="log-list">\n \x3c!-- Populated dynamically --\x3e\n </div>\n </div>\n\n <div class="actions">\n <a class="docs-link" id="docs-link" href="#" target="_blank" rel="noopener">\n <svg\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <path\n d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"\n />\n </svg>\n Docs\n </a>\n <button class="action-btn" id="btn-copy" title="Copy as JSON">\n <svg\n class="icon-default"\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>\n <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>\n </svg>\n <svg\n class="icon-check"\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <path d="M20 6 9 17l-5-5"/>\n </svg>\n </button>\n <button class="action-btn" id="btn-clear" title="Clear Data">\n <svg\n class="icon-default"\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <path d="M3 6h18"/>\n <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>\n <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>\n </svg>\n <svg\n class="icon-check"\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <path d="M20 6 9 17l-5-5"/>\n </svg>\n </button>\n <button class="action-btn" id="btn-refresh" title="Refresh Forms">\n <svg\n class="icon-default"\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>\n <path d="M3 3v5h5"/>\n <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>\n <path d="M16 16h5v5"/>\n </svg>\n <svg\n class="icon-check"\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n aria-hidden="true"\n >\n <path d="M20 6 9 17l-5-5"/>\n </svg>\n </button>\n </div>\n</div>\n\n\x3c!-- Collapsed Button --\x3e\n<div class="collapsed-btn hidden" id="collapsed" title="Expand Debug Panel">\n <svg\n xmlns="http://www.w3.org/2000/svg"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n stroke-width="2"\n stroke-linecap="round"\n stroke-linejoin="round"\n >\n <path d="m18 16 4-4-4-4"/>\n <path d="m6 8-4 4 4 4"/>\n <path d="m14.5 4-5 16"/>\n </svg>\n <span class="collapsed-badge"></span>\n</div>\n';const STORAGE_KEY="fa_debug_state";const COLLAPSED_WIDTH=40;const PANEL_WIDTH=340;let isCollapsed=false;let activeTab="data";let logEntries=[];let position={bottom:16,right:16};const loadState=()=>{try{const stored=sessionStorage.getItem(STORAGE_KEY);if(stored){const state=JSON.parse(stored);isCollapsed=state.isCollapsed??false;activeTab=state.activeTab??"data";position=state.position??{bottom:16,right:16}}}catch{}};const saveState=()=>{try{sessionStorage.setItem(STORAGE_KEY,JSON.stringify({isCollapsed:isCollapsed,activeTab:activeTab,position:position}))}catch{}};const formatTime=(date=new Date)=>date.toLocaleTimeString("en-US",{hour12:false,hour:"2-digit",minute:"2-digit",second:"2-digit"});const addLog=(message,type="info")=>{logEntries.unshift({time:formatTime(),message:message,type:type});if(logEntries.length>50){logEntries=logEntries.slice(0,50)}updateLogTab()};const truncate=(str,maxLen=24)=>{if(!str||str.length<=maxLen){return str||""}return`${str.substring(0,maxLen-3)}...`};const getShortPath=url=>{if(!url){return""}try{const u=new URL(url,window.location.origin);return u.pathname}catch{return url}};const flashSuccess=(btn,duration=1500)=>{btn.classList.add("success");setTimeout(()=>btn.classList.remove("success"),duration)};const highlightForm=formElement=>{if(!formElement){return}const originalOutline=formElement.style.outline;const originalOutlineOffset=formElement.style.outlineOffset;const originalTransition=formElement.style.transition;formElement.style.transition="outline-color 0.3s";formElement.style.outline="3px solid hsl(142 76% 36%)";formElement.style.outlineOffset="2px";formElement.scrollIntoView({behavior:"smooth",block:"center"});setTimeout(()=>{formElement.style.outline="3px solid transparent";setTimeout(()=>{formElement.style.outline=originalOutline;formElement.style.outlineOffset=originalOutlineOffset;formElement.style.transition=originalTransition},300)},1500)};const findFormBySelector=selector=>{if(!selector){return null}try{return document.querySelector(`form${selector}`)}catch{return null}};const el=(tag,attrs={},children=[])=>{const element=document.createElement(tag);for(const[key,value]of Object.entries(attrs)){if(key==="class"){element.className=value}else if(key==="text"){element.textContent=value}else{element.setAttribute(key,value)}}for(const child of Array.isArray(children)?children:[children]){if(typeof child==="string"){element.appendChild(document.createTextNode(child))}else if(child){element.appendChild(child)}}return element};const svg=(paths,size=12)=>{const ns="http://www.w3.org/2000/svg";const s=document.createElementNS(ns,"svg");s.setAttribute("xmlns",ns);s.setAttribute("viewBox","0 0 24 24");s.setAttribute("fill","none");s.setAttribute("stroke","currentColor");s.setAttribute("stroke-width","2");s.setAttribute("stroke-linecap","round");s.setAttribute("stroke-linejoin","round");s.style.width=`${size}px`;s.style.height=`${size}px`;for(const pathData of paths){if(typeof pathData==="string"){const path=document.createElementNS(ns,"path");path.setAttribute("d",pathData);s.appendChild(path)}else{const elem=document.createElementNS(ns,pathData.type||"path");for(const[k,v]of Object.entries(pathData)){if(k!=="type"){elem.setAttribute(k,v)}}s.appendChild(elem)}}return s};const iconPaths={form:[{type:"rect",width:"18",height:"18",x:"3",y:"3",rx:"2"},"M9 3v18","M3 9h6","M3 15h6"]};const clearChildren=element=>{while(element.firstChild){element.removeChild(element.firstChild)}};const createOverlay=()=>{const host=document.createElement("div");host.id="fa-debug-overlay";const shadow=host.attachShadow({mode:"closed"});shadow.innerHTML=`\n <style>\n :host {\n --fa-panel-width: ${PANEL_WIDTH}px;\n --fa-collapsed-width: ${COLLAPSED_WIDTH}px;\n }\n ${STYLES}\n </style>\n ${TEMPLATE}\n `;host.style.cssText=`\n position: fixed;\n bottom: ${position.bottom}px;\n right: ${position.right}px;\n z-index: 999999;\n `;document.body.appendChild(host);return{host:host,shadow:shadow}};let host,shadow;let panel,collapsed,statusBar,tabData,tabForms,logList;const initRefs=s=>{panel=s.getElementById("panel");collapsed=s.getElementById("collapsed");statusBar=s.getElementById("status-bar");tabData=s.getElementById("tab-data");tabForms=s.getElementById("tab-forms");logList=s.getElementById("log-list")};const applyState=s=>{panel.classList.toggle("hidden",isCollapsed);collapsed.classList.toggle("hidden",!isCollapsed);for(const tab of s.querySelectorAll(".tab")){tab.classList.toggle("active",tab.dataset.tab===activeTab)}for(const content of s.querySelectorAll(".tab-content")){content.classList.toggle("active",content.id===`tab-${activeTab}`)}};const updateStatusBar=()=>{const config=FA.config;const data=FA.getData();const forms=FA.getForms?.()||[];const paramCount=data?Object.keys(data).filter(k=>!k.startsWith("_")).length:0;clearChildren(statusBar);const statusItem1=el("div",{class:"status-item"},[el("span",{class:`status-dot ${paramCount>0?"":"inactive"}`}),el("span",{text:paramCount>0?"Active":"No Data"})]);const statusItem2=el("div",{class:"status-item"},[el("span",{text:"Storage:"}),el("span",{class:"status-value",text:config.storage||"session"})]);const statusItem3=el("div",{class:"status-item"},[el("span",{text:"Forms:"}),el("span",{class:"status-value",text:String(forms.length)})]);statusBar.append(statusItem1,statusItem2,statusItem3)};const createDataRow=(key,value,displayValue)=>el("div",{class:"data-row"},[el("span",{class:"data-key",text:key}),el("span",{class:"data-value",title:value,text:displayValue})]);const createDataSection=(title,badge,rows)=>{const fragment=document.createDocumentFragment();const headerChildren=[el("span",{text:title})];if(badge){headerChildren.push(el("span",{class:"badge",text:badge}))}fragment.appendChild(el("div",{class:"section-header"},headerChildren));const dataList=el("div",{class:"data-list"});for(const row of rows){dataList.appendChild(row)}fragment.appendChild(dataList);return fragment};const updateDataTab=()=>{const data=FA.getData();clearChildren(tabData);if(!data||Object.keys(data).length===0){tabData.appendChild(el("div",{class:"empty",text:"No attribution data available"}));return}const urlParams=[];const metaParams=[];const metaKeys=["landing_page","current_page","referrer_url","first_touch_timestamp"];for(const[key,value]of Object.entries(data)){if(metaKeys.includes(key)){metaParams.push({key:key,value:value})}else{urlParams.push({key:key,value:value})}}if(urlParams.length>0){const rows=urlParams.map(({key:key,value:value})=>createDataRow(key,value,truncate(value)));tabData.appendChild(createDataSection("URL Parameters",`${urlParams.length} captured`,rows))}if(metaParams.length>0){const rows=metaParams.map(({key:key,value:value})=>{const displayKey=key.replace(/_/g," ");const isUrl=key.includes("page")||key.includes("url");const displayValue=isUrl?truncate(getShortPath(value),20):truncate(value);return createDataRow(displayKey,value,displayValue)});tabData.appendChild(createDataSection("Metadata",null,rows))}};const getFormIdentifier=form=>{if(form.id){return`#${form.id}`}if(form.name){return`[name="${form.name}"]`}return form.selector||"[form]"};const getFormStatus=form=>{if(form.excluded){return{className:"excluded",text:"Excluded"}}if(form.injected){return{className:"injected",text:"Injected"}}return{className:"ready",text:"Ready"}};const createFormItem=form=>{const formName=el("div",{class:"form-name"},[svg(iconPaths.form),document.createTextNode(` ${getFormIdentifier(form)}`)]);const status=getFormStatus(form);const formStatus=el("span",{class:`form-status ${status.className}`,text:status.text});const canHighlight=Boolean(form.selector);const formItem=el("div",{class:canHighlight?"form-item":"form-item no-highlight",title:canHighlight?"Click to highlight":""},[formName,formStatus]);if(canHighlight){formItem.addEventListener("click",()=>{const formElement=findFormBySelector(form.selector);if(formElement){highlightForm(formElement);addLog(`Highlighted form ${getFormIdentifier(form)}`,"info")}})}return formItem};const updateFormsTab=()=>{const forms=FA.getForms?.()||[];clearChildren(tabForms);if(forms.length===0){const emptyState=el("div",{class:"empty"},[svg(iconPaths.form,24),document.createTextNode(" No forms found on page")]);tabForms.appendChild(emptyState);return}const dataList=el("div",{class:"data-list"});for(const form of forms){dataList.appendChild(createFormItem(form))}tabForms.appendChild(dataList)};const updateLogTab=()=>{if(!logList){return}clearChildren(logList);if(logEntries.length===0){logList.appendChild(el("div",{class:"empty",text:"No events logged yet"}));return}for(const entry of logEntries){const logEntry=el("div",{class:"log-entry"},[el("span",{class:"log-time",text:entry.time}),el("span",{class:`log-msg ${entry.type}`,text:entry.message})]);logList.appendChild(logEntry)}};const updateAll=()=>{updateStatusBar();updateDataTab();updateFormsTab();updateLogTab()};const toggleCollapsed=forceState=>{isCollapsed=forceState??!isCollapsed;panel.classList.toggle("hidden",isCollapsed);collapsed.classList.toggle("hidden",!isCollapsed);saveState()};const setupEvents=s=>{for(const tab of s.querySelectorAll(".tab")){tab.addEventListener("click",()=>{for(const t of s.querySelectorAll(".tab")){t.classList.remove("active")}for(const c of s.querySelectorAll(".tab-content")){c.classList.remove("active")}tab.classList.add("active");activeTab=tab.dataset.tab;s.getElementById(`tab-${activeTab}`).classList.add("active");saveState()})}s.getElementById("btn-minimize").addEventListener("click",()=>{toggleCollapsed(true)});s.getElementById("btn-close").addEventListener("click",()=>{host.remove();addLog("Debug overlay closed","warning")});s.getElementById("collapsed").addEventListener("click",()=>{toggleCollapsed(false)});const btnCopy=s.getElementById("btn-copy");btnCopy.addEventListener("click",async()=>{const data=FA.getData();try{await navigator.clipboard.writeText(JSON.stringify(data,null,2));flashSuccess(btnCopy);addLog("Data copied to clipboard","success")}catch{addLog("Failed to copy data","error")}});const btnClear=s.getElementById("btn-clear");btnClear.addEventListener("click",()=>{FA.clear?.();flashSuccess(btnClear);addLog("Storage cleared","warning");updateAll()});const btnRefresh=s.getElementById("btn-refresh");btnRefresh.addEventListener("click",()=>{FA.refresh?.();flashSuccess(btnRefresh);addLog("Forms refreshed","success");updateAll()});let isDragging=false;let startX,startY,startRight,startBottom;const dragHandle=s.getElementById("drag-handle");dragHandle.addEventListener("mousedown",e=>{if(e.target.closest(".ctrl-btn")){return}isDragging=true;startX=e.clientX;startY=e.clientY;startRight=position.right;startBottom=position.bottom;e.preventDefault()});document.addEventListener("mousemove",e=>{if(!isDragging){return}const deltaX=startX-e.clientX;const deltaY=startY-e.clientY;position.right=Math.max(0,Math.min(window.innerWidth-PANEL_WIDTH,startRight+deltaX));position.bottom=Math.max(0,Math.min(window.innerHeight-200,startBottom+deltaY));host.style.right=`${position.right}px`;host.style.bottom=`${position.bottom}px`});document.addEventListener("mouseup",()=>{if(isDragging){isDragging=false;saveState()}})};const setupCallbacks=()=>{if(typeof FA.on==="function"){FA.on("onCapture",({data:data})=>{const count=Object.keys(data||{}).length;addLog(`Captured ${count} parameters`,"success");updateAll()});FA.on("onUpdate",({forms:forms,action:action})=>{if(action==="clear"){addLog(`Cleared ${forms.length} form(s)`,"info")}else{addLog(`Injected into ${forms.length} form(s)`,"info")}updateAll()});FA.on("onReady",()=>{addLog("Initialized","success");updateAll()})}};const init=()=>{loadState();const overlay=createOverlay();host=overlay.host;shadow=overlay.shadow;initRefs(shadow);const docsLink=shadow.getElementById("docs-link");if(docsLink&&FA.docs){docsLink.href=FA.docs}applyState(shadow);setupEvents(shadow);setupCallbacks();addLog("Debug overlay loaded","success");updateAll()};if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",init,{once:true})}else{init()}})();
@@ -1 +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()})();
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;let storage=null;let latestData=null;const callbacks={onCapture:[],onUpdate:[],onReady:[]};const invokeCallback=(name,payload)=>{const listeners=callbacks[name];if(!Array.isArray(listeners)){return}for(const fn of listeners){if(typeof fn==="function"){try{fn(payload)}catch(e){log(`${name} callback error:`,e)}}}};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();invokeCallback("onCapture",{data:attributionData});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 getFormSelector=form=>{if(form.id){return`#${form.id}`}if(form.name){return`[name="${form.name}"]`}return null};const hasInjectedFields=form=>form.querySelector('input[data-form-attribution="true"]')!==null;const computeFormsList=()=>{const allForms=Array.from(document.querySelectorAll("form"));return allForms.map(form=>{const included=shouldIncludeForm(form);return{id:form.id||null,name:form.name||null,selector:getFormSelector(form),injected:included&&hasInjectedFields(form),excluded:!included}})};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]")}const action=entries.length===0?"clear":"inject";log(action==="clear"?`Cleared attribution fields in ${forms.length} form(s)`:`Injected attribution data into ${forms.length} form(s)`);invokeCallback("onUpdate",{forms:forms,entries:entries,data:data,action:action})};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 loadDebugOverlay=()=>{const scriptSrc=SCRIPT_ELEMENT?.getAttribute("src")||"";const debugUrl=scriptSrc.includes("cdn.jsdelivr.net")?scriptSrc.replace("script.min.js","debug.min.js"):"./dist/debug.min.js";const script=document.createElement("script");script.src=debugUrl;script.async=true;script.onerror=()=>log("Failed to load debug overlay from:",debugUrl);document.head.appendChild(script);log("Loading debug overlay from:",debugUrl)};const api={getData(){return latestData?{...latestData}:null},getParam(name){return latestData?.[name]??null},getForms(){return computeFormsList()},clear(){latestData=null;if(storage){storage.remove(CONFIG.storageKey)}injectIntoForms(null);log("Attribution data cleared")},refresh(){injectIntoForms(latestData);log("Forms refreshed")},on(event,callback){if(event in callbacks&&typeof callback==="function"){callbacks[event].push(callback)}return this},off(event,callback){if(event in callbacks){const idx=callbacks[event].indexOf(callback);if(idx!==-1){callbacks[event].splice(idx,1)}}return this},get config(){return{...CONFIG}},github:"https://github.com/Flash-Brew-Digital/form-attribution",docs:"https://form-attribution.flashbrew.digital/docs/"};window.FormAttribution=api;const init=async()=>{log("Initializing with config:",CONFIG);storage=createStorageAdapter(CONFIG.storage);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);invokeCallback("onReady",{data:latestData,config:CONFIG});if(CONFIG.debug){loadDebugOverlay()}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,14 @@
1
1
  {
2
2
  "name": "form-attribution",
3
- "version": "2.0.0",
3
+ "version": "2.5.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
8
  "main": "dist/script.min.js",
9
9
  "files": [
10
- "dist/script.min.js"
10
+ "dist/script.min.js",
11
+ "dist/debug.min.js"
11
12
  ],
12
13
  "repository": {
13
14
  "type": "git",
@@ -31,7 +32,8 @@
31
32
  "url": "https://github.com/flash-brew-digital/form-attribution/issues"
32
33
  },
33
34
  "scripts": {
34
- "build": "mkdir -p dist && npx terser src/script.js -o dist/script.min.js",
35
+ "build": "node scripts/build.js",
36
+ "minify-assets": "bash scripts/minify-assets.sh",
35
37
  "release": "pnpm build && npm publish --access public",
36
38
  "check": "npx ultracite check",
37
39
  "fix": "npx ultracite fix",
@@ -41,6 +43,8 @@
41
43
  "@biomejs/biome": "2.3.10",
42
44
  "@playwright/test": "^1.57.0",
43
45
  "@types/node": "^25.0.3",
46
+ "clean-css-cli": "^5.6.3",
47
+ "terser": "^5.44.1",
44
48
  "ultracite": "6.5.0"
45
49
  },
46
50
  "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"