+ );
+}
+```
+
### Persistent Storage
By default, UTM parameters are stored in `sessionStorage` (cleared when the tab closes). For longer-lived storage, use `localStorage` with an optional TTL.
@@ -357,18 +606,23 @@ import { useUtmTracking } from '@jackmisner/utm-toolkit/react';
function MyComponent() {
const {
- utmParameters, // Current captured params (or null)
- isEnabled, // Whether tracking is enabled
- hasParams, // Whether any params exist
- capture, // Manually capture from URL
- clear, // Clear stored params
- appendToUrl, // Append params to a URL
+ utmParameters, // Current captured params (or null)
+ isEnabled, // Whether tracking is enabled
+ hasParams, // Whether any params exist
+ capture, // Manually capture from URL
+ clear, // Clear stored params
+ appendToUrl, // Append params to a URL
+ firstTouchParams, // First-touch params (null when attribution mode is 'last')
+ lastTouchParams, // Last-touch params (null when attribution mode is 'first')
} = useUtmTracking({
config: {
keyFormat: 'camelCase',
+ attribution: { mode: 'both' },
shareContextParams: {
linkedin: { utm_content: 'linkedin' },
},
+ onCapture: (params) => analytics.track('utm_captured', params),
+ onStore: (params, meta) => analytics.track('utm_stored', { ...params, touch: meta.touch }),
},
});
@@ -415,6 +669,12 @@ installDebugHelpers();
| `excludeFromShares` | `string[]` | `[]` | Params to exclude from shares |
| `sanitize` | `SanitizeConfig` | `{ enabled: false }` | Value sanitization settings |
| `piiFiltering` | `PiiFilterConfig` | `{ enabled: false }` | PII detection and filtering |
+| `attribution` | `AttributionConfig` | `{ mode: 'last' }` | First-touch/last-touch attribution |
+| `onCapture` | `function` | `undefined` | Callback after UTM params are captured |
+| `onStore` | `function` | `undefined` | Callback after UTM params are stored |
+| `onClear` | `function` | `undefined` | Callback when stored params are cleared |
+| `onAppend` | `function` | `undefined` | Callback after UTM params are appended to a URL |
+| `onExpire` | `function` | `undefined` | Callback when stored params expire (TTL) |
## TypeScript Types
@@ -423,10 +683,15 @@ import type {
UtmParameters,
UtmConfig,
StorageType,
+ KeyFormat,
SanitizeConfig,
PiiFilterConfig,
PiiPattern,
SharePlatform,
+ AttributionMode,
+ AttributionConfig,
+ TouchType,
+ ValidationResult,
UseUtmTrackingReturn,
} from '@jackmisner/utm-toolkit';
```
diff --git a/__tests__/common/events.test.ts b/__tests__/common/events.test.ts
new file mode 100644
index 0000000..a4e1b09
--- /dev/null
+++ b/__tests__/common/events.test.ts
@@ -0,0 +1,197 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { captureUtmParameters } from '../../src/inbound/capture'
+import {
+ storeUtmParameters,
+ getStoredUtmParameters,
+ clearStoredUtmParameters,
+} from '../../src/common/storage'
+import { appendUtmParameters } from '../../src/outbound/appender'
+
+describe('Event Callbacks', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ })
+
+ describe('onCapture', () => {
+ it('fires with captured params when UTM params found', () => {
+ const onCapture = vi.fn()
+ const result = captureUtmParameters('https://example.com?utm_source=google&utm_medium=cpc', {
+ onCapture,
+ })
+ expect(onCapture).toHaveBeenCalledOnce()
+ expect(onCapture).toHaveBeenCalledWith(result)
+ expect(result).toEqual({ utm_source: 'google', utm_medium: 'cpc' })
+ })
+
+ it('does not fire when no UTM params found', () => {
+ const onCapture = vi.fn()
+ captureUtmParameters('https://example.com?foo=bar', { onCapture })
+ expect(onCapture).not.toHaveBeenCalled()
+ })
+
+ it('does not fire when URL is invalid', () => {
+ const onCapture = vi.fn()
+ captureUtmParameters('not a valid url', { onCapture })
+ expect(onCapture).not.toHaveBeenCalled()
+ })
+
+ it('is optional — works without callback', () => {
+ const result = captureUtmParameters('https://example.com?utm_source=test')
+ expect(result).toEqual({ utm_source: 'test' })
+ })
+
+ it('does not break capture if callback throws', () => {
+ const onCapture = vi.fn(() => {
+ throw new Error('callback error')
+ })
+ const result = captureUtmParameters('https://example.com?utm_source=google', { onCapture })
+ expect(result).toEqual({ utm_source: 'google' })
+ })
+ })
+
+ describe('onStore', () => {
+ it('fires with params and meta after storing', () => {
+ const onStore = vi.fn()
+ storeUtmParameters({ utm_source: 'google' }, { onStore, storageType: 'session' })
+ expect(onStore).toHaveBeenCalledOnce()
+ expect(onStore).toHaveBeenCalledWith({ utm_source: 'google' }, { storageType: 'session' })
+ })
+
+ it('fires with correct storageType for localStorage', () => {
+ const onStore = vi.fn()
+ storeUtmParameters({ utm_source: 'linkedin' }, { onStore, storageType: 'local' })
+ expect(onStore).toHaveBeenCalledWith({ utm_source: 'linkedin' }, { storageType: 'local' })
+ })
+
+ it('does not fire when params are empty', () => {
+ const onStore = vi.fn()
+ storeUtmParameters({}, { onStore })
+ expect(onStore).not.toHaveBeenCalled()
+ })
+
+ it('is optional — works without callback', () => {
+ storeUtmParameters({ utm_source: 'test' })
+ const stored = getStoredUtmParameters({ keyFormat: 'snake_case' })
+ expect(stored).toEqual({ utm_source: 'test' })
+ })
+
+ it('does not break storage if callback throws', () => {
+ const onStore = vi.fn(() => {
+ throw new Error('callback error')
+ })
+ storeUtmParameters({ utm_source: 'google' }, { onStore })
+ const stored = getStoredUtmParameters({ keyFormat: 'snake_case' })
+ expect(stored).toEqual({ utm_source: 'google' })
+ })
+ })
+
+ describe('onClear', () => {
+ it('fires when stored params are cleared', () => {
+ const onClear = vi.fn()
+ storeUtmParameters({ utm_source: 'google' })
+ clearStoredUtmParameters({ onClear })
+ expect(onClear).toHaveBeenCalledOnce()
+ })
+
+ it('is optional — works without callback', () => {
+ storeUtmParameters({ utm_source: 'test' })
+ clearStoredUtmParameters()
+ const stored = getStoredUtmParameters()
+ expect(stored).toBeNull()
+ })
+
+ it('does not break clearing if callback throws', () => {
+ const onClear = vi.fn(() => {
+ throw new Error('callback error')
+ })
+ storeUtmParameters({ utm_source: 'google' })
+ clearStoredUtmParameters({ onClear })
+ const stored = getStoredUtmParameters()
+ expect(stored).toBeNull()
+ })
+ })
+
+ describe('onExpire', () => {
+ it('fires when expired data is auto-cleaned during retrieval', () => {
+ const onExpire = vi.fn()
+ // Store with a TTL that has already expired
+ const now = Date.now()
+ vi.spyOn(Date, 'now')
+ .mockReturnValueOnce(now) // for storeUtmParameters
+ .mockReturnValueOnce(now + 10000) // for getStoredUtmParameters (expired)
+ storeUtmParameters({ utm_source: 'google' }, { storageType: 'local', ttl: 5000 })
+ const result = getStoredUtmParameters({ storageType: 'local', onExpire })
+ expect(result).toBeNull()
+ expect(onExpire).toHaveBeenCalledOnce()
+ expect(onExpire).toHaveBeenCalledWith('utm_parameters')
+ vi.restoreAllMocks()
+ })
+
+ it('does not fire when data is not expired', () => {
+ const onExpire = vi.fn()
+ storeUtmParameters({ utm_source: 'google' }, { storageType: 'local', ttl: 60000 })
+ getStoredUtmParameters({ storageType: 'local', onExpire })
+ expect(onExpire).not.toHaveBeenCalled()
+ })
+
+ it('does not fire for sessionStorage (no TTL)', () => {
+ const onExpire = vi.fn()
+ storeUtmParameters({ utm_source: 'google' })
+ getStoredUtmParameters({ onExpire })
+ expect(onExpire).not.toHaveBeenCalled()
+ })
+
+ it('does not break retrieval if callback throws', () => {
+ const onExpire = vi.fn(() => {
+ throw new Error('callback error')
+ })
+ const now = Date.now()
+ vi.spyOn(Date, 'now')
+ .mockReturnValueOnce(now)
+ .mockReturnValueOnce(now + 10000)
+ storeUtmParameters({ utm_source: 'google' }, { storageType: 'local', ttl: 5000 })
+ const result = getStoredUtmParameters({ storageType: 'local', onExpire })
+ expect(result).toBeNull()
+ vi.restoreAllMocks()
+ })
+ })
+
+ describe('onAppend', () => {
+ it('fires with the final URL and params after appending', () => {
+ const onAppend = vi.fn()
+ const result = appendUtmParameters(
+ 'https://example.com',
+ { utm_source: 'google' },
+ { onAppend },
+ )
+ expect(onAppend).toHaveBeenCalledOnce()
+ expect(onAppend).toHaveBeenCalledWith(result, { utm_source: 'google' })
+ })
+
+ it('does not fire when no valid UTM entries', () => {
+ const onAppend = vi.fn()
+ appendUtmParameters('https://example.com', {}, { onAppend })
+ expect(onAppend).not.toHaveBeenCalled()
+ })
+
+ it('is optional — works without callback', () => {
+ const result = appendUtmParameters('https://example.com', {
+ utm_source: 'test',
+ })
+ expect(result).toContain('utm_source=test')
+ })
+
+ it('does not break appending if callback throws', () => {
+ const onAppend = vi.fn(() => {
+ throw new Error('callback error')
+ })
+ const result = appendUtmParameters(
+ 'https://example.com',
+ { utm_source: 'google' },
+ { onAppend },
+ )
+ expect(result).toContain('utm_source=google')
+ })
+ })
+})
diff --git a/__tests__/core/keys.test.ts b/__tests__/common/keys.test.ts
similarity index 99%
rename from __tests__/core/keys.test.ts
rename to __tests__/common/keys.test.ts
index 05b3424..af2199d 100644
--- a/__tests__/core/keys.test.ts
+++ b/__tests__/common/keys.test.ts
@@ -12,7 +12,7 @@ import {
detectKeyFormat,
normalizeKey,
toUrlKey,
-} from '../../src/core/keys'
+} from '../../src/common/keys'
describe('toSnakeCase', () => {
it('converts standard camelCase keys to snake_case', () => {
diff --git a/__tests__/core/storage.test.ts b/__tests__/common/storage.test.ts
similarity index 99%
rename from __tests__/core/storage.test.ts
rename to __tests__/common/storage.test.ts
index 2743a1c..df31eb2 100644
--- a/__tests__/core/storage.test.ts
+++ b/__tests__/common/storage.test.ts
@@ -9,7 +9,7 @@ import {
isStorageAvailable,
getRawStoredValue,
DEFAULT_STORAGE_KEY,
-} from '../../src/core/storage'
+} from '../../src/common/storage'
describe('storeUtmParameters', () => {
beforeEach(() => {
diff --git a/__tests__/core/validator.test.ts b/__tests__/common/validator.test.ts
similarity index 99%
rename from __tests__/core/validator.test.ts
rename to __tests__/common/validator.test.ts
index 0071630..f224311 100644
--- a/__tests__/core/validator.test.ts
+++ b/__tests__/common/validator.test.ts
@@ -9,7 +9,7 @@ import {
getAllowedProtocols,
isProtocolAllowed,
getErrorMessage,
-} from '../../src/core/validator'
+} from '../../src/common/validator'
describe('validateUrl', () => {
describe('valid URLs', () => {
diff --git a/__tests__/docs.md b/__tests__/docs.md
index 8814294..e45df2b 100644
--- a/__tests__/docs.md
+++ b/__tests__/docs.md
@@ -4,23 +4,25 @@ Path: @/__tests__
### Overview
-- Test suite for the library, mirroring the `@/src` directory structure with subdirectories for `config/`, `core/`, and `react/`.
+- Test suite for the library, mirroring the `@/src` directory structure with subdirectories for `common/`, `inbound/`, `outbound/`, `config/`, and `react/`.
- Uses vitest with jsdom environment, `@testing-library/react` for React component/hook tests, and a global setup file for browser API mocks.
- Coverage thresholds are enforced at 80% for statements, branches, functions, and lines (configured in `@/vitest.config.ts`).
### How it fits into the larger codebase
-- Tests exercise all public API surfaces from `@/src/core`, `@/src/config`, and `@/src/react`.
+- Tests exercise all public API surfaces from `@/src/common`, `@/src/inbound`, `@/src/outbound`, `@/src/config`, and `@/src/react`.
- Coverage excludes barrel `index.ts` files and the `@/src/types` directory, since these contain only re-exports and type definitions.
-- The test setup (`setup.ts`) provides the mock environment that all tests rely on: a sessionStorage mock backed by a plain object with `vi.fn()` wrappers, and a `window.location` mock defaulting to `https://example.com`.
+- The test setup (`setup.ts`) provides the mock environment that all tests rely on: sessionStorage and localStorage mocks backed by plain objects with `vi.fn()` wrappers, and a `window.location` mock defaulting to `https://example.com`.
- CI runs tests across Node 18, 20, and 22.
### Core Implementation
- **`setup.ts`**: Creates fresh sessionStorage and localStorage mocks and a location mock in `beforeEach`, ensuring tests are isolated. Both storage mocks implement `getItem`, `setItem`, `removeItem`, `clear`, `length`, and `key`. Location is stubbed with `href`, `search`, `hash`, `pathname`, `protocol`, `host`, and `hostname`.
-- **`core/` tests**: Cover capture (URL parsing, allowed parameters, key format conversion, SSR fallback, sanitization integration, PII filtering integration), sanitizer (HTML stripping, control character removal, custom patterns, truncation, combined rules), pii-filter (pattern detection, reject/redact modes, allowlist, callback, disabled patterns, edge cases), storage (write/read/clear, format conversion, validation of stored data, silent failure, localStorage backend, envelope format, TTL expiration with fake timers, backward compatibility with flat format data, `isStorageAvailable`/`isLocalStorageAvailable` availability checks), appender (query/fragment placement, preserveExisting, remove, extract), keys (bidirectional conversion, standard and custom keys, detection, validation), and validator (protocol, domain, normalization, mutable default protocol).
-- **`config/` tests**: Cover `createConfig` merging semantics (nullish coalescing, array replacement, object merge, `storageType` and `ttl` merging), `validateConfig` error messages (including `storageType` and `ttl` validation), `loadConfigFromJson` fallback behavior, sanitize config handling (default inclusion, partial merge, custom pattern preservation, validation of each sanitize field), and piiFiltering config handling (default inclusion, partial merge, custom patterns replacement, mode validation).
-- **`react/` tests**: Use `@testing-library/react` `renderHook` and `render` to test `useUtmTracking` (auto-capture, manual capture, clear, appendToUrl with share context and exclusions, sanitization, PII filtering, `storageType` forwarding to storage calls) and `UtmProvider`/`useUtmContext` (context propagation, error on missing provider).
+- **`common/` tests**: Cover storage (write/read/clear, format conversion, validation of stored data, silent failure, localStorage backend, envelope format, TTL expiration with fake timers, backward compatibility with flat format data, availability checks, event callbacks), keys (bidirectional conversion, standard and custom keys, detection, validation), validator (protocol, domain, normalization, mutable default protocol), and event callback integration.
+- **`inbound/` tests**: Cover capture (URL parsing, allowed parameters, key format conversion, SSR fallback, sanitization integration, PII filtering integration), sanitizer (HTML stripping, control character removal, custom patterns, truncation), pii-filter (pattern detection, reject/redact modes, allowlist, callback), attribution (first-touch/last-touch/both modes, write-once semantics), and form field population (name/data-attribute/auto-create strategies).
+- **`outbound/` tests**: Cover appender (query/fragment placement, preserveExisting, remove, extract), builder (structured URL construction, validation, warnings, lowercase option), and decorator (link decoration, host filtering, skip-existing, MutationObserver).
+- **`config/` tests**: Cover `createConfig` merging semantics (including `storageType`, `ttl`, attribution, and event callbacks), `validateConfig` error messages, `loadConfigFromJson` fallback behavior, and nested config merging for sanitize and piiFiltering.
+- **`react/` tests**: Use `@testing-library/react` `renderHook` and `render` to test `useUtmTracking` (auto-capture, manual capture, clear, appendToUrl, `storageType` forwarding, attribution params), `UtmProvider`/`useUtmContext`, `UtmHiddenFields`, and `UtmLinkDecorator`.
### Things to Know
diff --git a/__tests__/inbound/attribution.test.ts b/__tests__/inbound/attribution.test.ts
new file mode 100644
index 0000000..9ecd846
--- /dev/null
+++ b/__tests__/inbound/attribution.test.ts
@@ -0,0 +1,358 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { storeWithAttribution, getAttributedParams } from '../../src/inbound/attribution'
+import { getStoredUtmParameters } from '../../src/common/storage'
+import type { AttributionConfig } from '../../src/types'
+
+describe('storeWithAttribution', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ })
+
+ describe('mode: last (default behavior)', () => {
+ const config: AttributionConfig = {
+ mode: 'last',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+
+ it('stores params to main key (same as current behavior)', () => {
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ const stored = getStoredUtmParameters({
+ storageKey: 'utm_parameters',
+ keyFormat: 'snake_case',
+ })
+ expect(stored).toEqual({ utm_source: 'google' })
+ })
+
+ it('overwrites existing params on subsequent calls', () => {
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const stored = getStoredUtmParameters({
+ storageKey: 'utm_parameters',
+ keyFormat: 'snake_case',
+ })
+ expect(stored).toEqual({ utm_source: 'facebook' })
+ })
+
+ it('does not write to first-touch or last-touch suffixed keys', () => {
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ expect(getStoredUtmParameters({ storageKey: 'utm_parameters_first' })).toBeNull()
+ expect(getStoredUtmParameters({ storageKey: 'utm_parameters_last' })).toBeNull()
+ })
+ })
+
+ describe('mode: first', () => {
+ const config: AttributionConfig = {
+ mode: 'first',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+
+ it('writes to first-touch key on first visit', () => {
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ const first = getStoredUtmParameters({
+ storageKey: 'utm_parameters_first',
+ keyFormat: 'snake_case',
+ })
+ expect(first).toEqual({ utm_source: 'google' })
+ })
+
+ it('does not overwrite first-touch on subsequent visits (write-once)', () => {
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const first = getStoredUtmParameters({
+ storageKey: 'utm_parameters_first',
+ keyFormat: 'snake_case',
+ })
+ expect(first).toEqual({ utm_source: 'google' })
+ })
+
+ it('also writes to main key', () => {
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ const main = getStoredUtmParameters({ storageKey: 'utm_parameters', keyFormat: 'snake_case' })
+ expect(main).toEqual({ utm_source: 'google' })
+ })
+ })
+
+ describe('mode: both', () => {
+ const config: AttributionConfig = {
+ mode: 'both',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+
+ it('writes to both first-touch and last-touch keys', () => {
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ const first = getStoredUtmParameters({
+ storageKey: 'utm_parameters_first',
+ keyFormat: 'snake_case',
+ })
+ const last = getStoredUtmParameters({
+ storageKey: 'utm_parameters_last',
+ keyFormat: 'snake_case',
+ })
+ expect(first).toEqual({ utm_source: 'google' })
+ expect(last).toEqual({ utm_source: 'google' })
+ })
+
+ it('first-touch is write-once, last-touch always updates', () => {
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const first = getStoredUtmParameters({
+ storageKey: 'utm_parameters_first',
+ keyFormat: 'snake_case',
+ })
+ const last = getStoredUtmParameters({
+ storageKey: 'utm_parameters_last',
+ keyFormat: 'snake_case',
+ })
+ expect(first).toEqual({ utm_source: 'google' })
+ expect(last).toEqual({ utm_source: 'facebook' })
+ })
+
+ it('also writes to main key (last-touch)', () => {
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const main = getStoredUtmParameters({ storageKey: 'utm_parameters', keyFormat: 'snake_case' })
+ expect(main).toEqual({ utm_source: 'facebook' })
+ })
+ })
+
+ describe('custom suffixes', () => {
+ it('uses custom first and last touch suffixes', () => {
+ const config: AttributionConfig = {
+ mode: 'both',
+ firstTouchSuffix: '.ft',
+ lastTouchSuffix: '.lt',
+ }
+ storeWithAttribution(
+ { utm_source: 'google' },
+ { attribution: config, storageKey: 'utm', storageType: 'session', keyFormat: 'snake_case' },
+ )
+ expect(getStoredUtmParameters({ storageKey: 'utm.ft', keyFormat: 'snake_case' })).toEqual({
+ utm_source: 'google',
+ })
+ expect(getStoredUtmParameters({ storageKey: 'utm.lt', keyFormat: 'snake_case' })).toEqual({
+ utm_source: 'google',
+ })
+ })
+ })
+
+ describe('onStore callback', () => {
+ it('fires with touch type for first mode', () => {
+ const onStore = vi.fn()
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: { mode: 'first', firstTouchSuffix: '_first', lastTouchSuffix: '_last' },
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ onStore,
+ },
+ )
+ expect(onStore).toHaveBeenCalledWith(
+ { utm_source: 'google' },
+ { storageType: 'session', touch: 'first' },
+ )
+ })
+
+ it('fires with touch type for both mode', () => {
+ const onStore = vi.fn()
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: { mode: 'both', firstTouchSuffix: '_first', lastTouchSuffix: '_last' },
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ onStore,
+ },
+ )
+ // Should fire twice - once for first, once for last
+ expect(onStore).toHaveBeenCalledTimes(2)
+ expect(onStore).toHaveBeenCalledWith(
+ { utm_source: 'google' },
+ { storageType: 'session', touch: 'first' },
+ )
+ expect(onStore).toHaveBeenCalledWith(
+ { utm_source: 'google' },
+ { storageType: 'session', touch: 'last' },
+ )
+ })
+ })
+})
+
+describe('getAttributedParams', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ })
+
+ it('returns main key params with no touch specified (default last mode)', () => {
+ const config: AttributionConfig = {
+ mode: 'last',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ const result = getAttributedParams({
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ })
+ expect(result).toEqual({ utm_source: 'google' })
+ })
+
+ it('returns first-touch params when touch=first', () => {
+ const config: AttributionConfig = {
+ mode: 'both',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const result = getAttributedParams({ ...opts, touch: 'first' })
+ expect(result).toEqual({ utm_source: 'google' })
+ })
+
+ it('returns last-touch params when touch=last', () => {
+ const config: AttributionConfig = {
+ mode: 'both',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const result = getAttributedParams({ ...opts, touch: 'last' })
+ expect(result).toEqual({ utm_source: 'facebook' })
+ })
+
+ it('defaults to first-touch when mode is first', () => {
+ const config: AttributionConfig = {
+ mode: 'first',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+ storeWithAttribution(
+ { utm_source: 'google' },
+ {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ },
+ )
+ const result = getAttributedParams({
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session',
+ keyFormat: 'snake_case',
+ })
+ expect(result).toEqual({ utm_source: 'google' })
+ })
+
+ it('defaults to last-touch when mode is both', () => {
+ const config: AttributionConfig = {
+ mode: 'both',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+ }
+ const opts = {
+ attribution: config,
+ storageKey: 'utm_parameters',
+ storageType: 'session' as const,
+ keyFormat: 'snake_case' as const,
+ }
+ storeWithAttribution({ utm_source: 'google' }, opts)
+ storeWithAttribution({ utm_source: 'facebook' }, opts)
+ const result = getAttributedParams(opts)
+ expect(result).toEqual({ utm_source: 'facebook' })
+ })
+})
diff --git a/__tests__/core/capture.test.ts b/__tests__/inbound/capture.test.ts
similarity index 99%
rename from __tests__/core/capture.test.ts
rename to __tests__/inbound/capture.test.ts
index a6b6a3e..d801aef 100644
--- a/__tests__/core/capture.test.ts
+++ b/__tests__/inbound/capture.test.ts
@@ -3,7 +3,7 @@ import {
captureUtmParameters,
hasUtmParameters,
captureFromCurrentUrl,
-} from '../../src/core/capture'
+} from '../../src/inbound/capture'
describe('captureUtmParameters', () => {
describe('basic extraction', () => {
diff --git a/__tests__/inbound/form.test.ts b/__tests__/inbound/form.test.ts
new file mode 100644
index 0000000..87a0649
--- /dev/null
+++ b/__tests__/inbound/form.test.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { populateFormFields, createUtmHiddenFields } from '../../src/inbound/form'
+import { storeUtmParameters } from '../../src/common/storage'
+
+/**
+ * Helper to set up DOM elements for testing.
+ * Uses DOM APIs instead of innerHTML to avoid XSS concerns in test code.
+ * Note: These are test-only helpers using static, trusted content in jsdom.
+ */
+function createForm(id?: string): HTMLFormElement {
+ const form = document.createElement('form')
+ if (id) form.id = id
+ document.body.appendChild(form)
+ return form
+}
+
+function createInput(form: HTMLFormElement, attrs: Record): HTMLInputElement {
+ const input = document.createElement('input')
+ for (const [key, value] of Object.entries(attrs)) {
+ input.setAttribute(key, value)
+ }
+ form.appendChild(input)
+ return input
+}
+
+describe('populateFormFields', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ document.body.replaceChildren()
+ })
+
+ describe('strategy: name', () => {
+ it('populates input fields matching utm parameter names', () => {
+ storeUtmParameters({ utm_source: 'google', utm_medium: 'cpc' })
+ const form = createForm()
+ createInput(form, { name: 'utm_source' })
+ createInput(form, { name: 'utm_medium' })
+ createInput(form, { name: 'other_field' })
+
+ const count = populateFormFields({ strategy: 'name' })
+ expect(count).toBe(2)
+ const sourceInput = document.querySelector('input[name="utm_source"]') as HTMLInputElement
+ const mediumInput = document.querySelector('input[name="utm_medium"]') as HTMLInputElement
+ expect(sourceInput.value).toBe('google')
+ expect(mediumInput.value).toBe('cpc')
+ })
+
+ it('returns 0 when no matching fields exist', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const form = createForm()
+ createInput(form, { name: 'email' })
+ const count = populateFormFields({ strategy: 'name' })
+ expect(count).toBe(0)
+ })
+
+ it('returns 0 when no params are stored', () => {
+ const form = createForm()
+ createInput(form, { name: 'utm_source' })
+ const count = populateFormFields({ strategy: 'name' })
+ expect(count).toBe(0)
+ })
+ })
+
+ describe('strategy: data-attribute', () => {
+ it('populates input fields with data-utm attributes', () => {
+ storeUtmParameters({ utm_source: 'google', utm_medium: 'cpc' })
+ const form = createForm()
+ createInput(form, { 'data-utm': 'source' })
+ createInput(form, { 'data-utm': 'medium' })
+
+ const count = populateFormFields({ strategy: 'data-attribute' })
+ expect(count).toBe(2)
+ const sourceInput = document.querySelector('input[data-utm="source"]') as HTMLInputElement
+ expect(sourceInput.value).toBe('google')
+ })
+
+ it('supports custom data attribute name', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const form = createForm()
+ createInput(form, { 'data-tracking': 'source' })
+ const count = populateFormFields({
+ strategy: 'data-attribute',
+ dataAttribute: 'data-tracking',
+ })
+ expect(count).toBe(1)
+ })
+ })
+
+ describe('strategy: auto-create', () => {
+ it('creates hidden inputs in matching forms', () => {
+ storeUtmParameters({ utm_source: 'google', utm_medium: 'cpc' })
+ createForm('myform')
+ const count = populateFormFields({ strategy: 'auto-create' })
+ expect(count).toBe(2)
+ const inputs = document.querySelectorAll('input[type="hidden"]')
+ expect(inputs.length).toBe(2)
+ })
+ })
+
+ describe('options', () => {
+ it('uses custom CSS selector', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const trackForm = createForm()
+ trackForm.className = 'track'
+ createInput(trackForm, { name: 'utm_source' })
+
+ const noTrackForm = createForm()
+ noTrackForm.className = 'no-track'
+ createInput(noTrackForm, { name: 'utm_source' })
+
+ const count = populateFormFields({ strategy: 'name', selector: 'form.track' })
+ expect(count).toBe(1)
+ })
+
+ it('uses custom storage key', () => {
+ storeUtmParameters({ utm_source: 'google' }, { storageKey: 'custom_utm' })
+ const form = createForm()
+ createInput(form, { name: 'utm_source' })
+ const count = populateFormFields({ strategy: 'name', storageKey: 'custom_utm' })
+ expect(count).toBe(1)
+ })
+ })
+})
+
+describe('createUtmHiddenFields', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ document.body.replaceChildren()
+ })
+
+ it('creates hidden input elements in forms', () => {
+ storeUtmParameters({ utm_source: 'google', utm_campaign: 'spring' })
+ createForm()
+ const count = createUtmHiddenFields()
+ expect(count).toBe(2)
+ const inputs = document.querySelectorAll('input[type="hidden"]')
+ expect(inputs.length).toBe(2)
+ })
+
+ it('sets correct names and values', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createForm()
+ createUtmHiddenFields()
+ const input = document.querySelector('input[name="utm_source"]') as HTMLInputElement
+ expect(input).not.toBeNull()
+ expect(input.type).toBe('hidden')
+ expect(input.value).toBe('google')
+ })
+
+ it('returns 0 when no params stored', () => {
+ createForm()
+ const count = createUtmHiddenFields()
+ expect(count).toBe(0)
+ })
+
+ it('works with multiple forms', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createForm()
+ createForm()
+ const count = createUtmHiddenFields()
+ expect(count).toBe(2) // 1 field x 2 forms
+ })
+})
diff --git a/__tests__/core/pii-filter.test.ts b/__tests__/inbound/pii-filter.test.ts
similarity index 99%
rename from __tests__/core/pii-filter.test.ts
rename to __tests__/inbound/pii-filter.test.ts
index dc77405..2863129 100644
--- a/__tests__/core/pii-filter.test.ts
+++ b/__tests__/inbound/pii-filter.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
-import { detectPii, filterValue, filterParams } from '../../src/core/pii-filter'
+import { detectPii, filterValue, filterParams } from '../../src/inbound/pii-filter'
import type { PiiFilterConfig, PiiPattern } from '../../src/types'
const defaultPatterns: PiiPattern[] = [
diff --git a/__tests__/core/sanitizer.test.ts b/__tests__/inbound/sanitizer.test.ts
similarity index 98%
rename from __tests__/core/sanitizer.test.ts
rename to __tests__/inbound/sanitizer.test.ts
index 20a7184..3f1d166 100644
--- a/__tests__/core/sanitizer.test.ts
+++ b/__tests__/inbound/sanitizer.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { sanitizeValue, sanitizeParams } from '../../src/core/sanitizer'
+import { sanitizeValue, sanitizeParams } from '../../src/inbound/sanitizer'
import type { SanitizeConfig } from '../../src/types'
const defaultConfig: SanitizeConfig = {
diff --git a/__tests__/core/appender.test.ts b/__tests__/outbound/appender.test.ts
similarity index 99%
rename from __tests__/core/appender.test.ts
rename to __tests__/outbound/appender.test.ts
index 623a085..fde542e 100644
--- a/__tests__/core/appender.test.ts
+++ b/__tests__/outbound/appender.test.ts
@@ -3,7 +3,7 @@ import {
appendUtmParameters,
removeUtmParameters,
extractUtmParameters,
-} from '../../src/core/appender'
+} from '../../src/outbound/appender'
describe('appendUtmParameters', () => {
describe('basic appending', () => {
diff --git a/__tests__/outbound/builder.test.ts b/__tests__/outbound/builder.test.ts
new file mode 100644
index 0000000..9d5cc3d
--- /dev/null
+++ b/__tests__/outbound/builder.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi } from 'vitest'
+import { buildUtmUrl, validateUtmValues } from '../../src/outbound/builder'
+
+describe('buildUtmUrl', () => {
+ describe('valid builds', () => {
+ it('builds a URL with required source param', () => {
+ const result = buildUtmUrl({ url: 'https://example.com', source: 'google' })
+ expect(result.valid).toBe(true)
+ expect(result.errors).toEqual([])
+ expect(result.url).toContain('utm_source=google')
+ })
+
+ it('builds a URL with all standard params', () => {
+ const result = buildUtmUrl({
+ url: 'https://example.com',
+ source: 'google',
+ medium: 'cpc',
+ campaign: 'spring2025',
+ term: 'shoes',
+ content: 'banner1',
+ id: '123',
+ })
+ expect(result.valid).toBe(true)
+ expect(result.url).toContain('utm_source=google')
+ expect(result.url).toContain('utm_medium=cpc')
+ expect(result.url).toContain('utm_campaign=spring2025')
+ expect(result.url).toContain('utm_term=shoes')
+ expect(result.url).toContain('utm_content=banner1')
+ expect(result.url).toContain('utm_id=123')
+ })
+
+ it('preserves existing query params on the base URL', () => {
+ const result = buildUtmUrl({
+ url: 'https://example.com?page=1',
+ source: 'google',
+ })
+ expect(result.url).toContain('page=1')
+ expect(result.url).toContain('utm_source=google')
+ })
+ })
+
+ describe('URL normalization', () => {
+ it('adds https:// if missing when normalize is true (default)', () => {
+ const result = buildUtmUrl({ url: 'example.com', source: 'google' })
+ expect(result.valid).toBe(true)
+ expect(result.url).toMatch(/^https:\/\//)
+ })
+
+ it('does not normalize when normalize is false', () => {
+ const result = buildUtmUrl({ url: 'example.com', source: 'google' }, { normalize: false })
+ expect(result.valid).toBe(false)
+ expect(result.errors).toContain('url is invalid')
+ })
+ })
+
+ describe('validation', () => {
+ it('returns error when source is empty', () => {
+ const result = buildUtmUrl({ url: 'https://example.com', source: '' })
+ expect(result.valid).toBe(false)
+ expect(result.errors).toContain('source is required')
+ })
+
+ it('returns error when URL is invalid', () => {
+ const result = buildUtmUrl({ url: 'not a url', source: 'google' }, { normalize: false })
+ expect(result.valid).toBe(false)
+ expect(result.errors).toContain('url is invalid')
+ })
+
+ it('returns error when param values contain unsafe characters', () => {
+ const result = buildUtmUrl({
+ url: 'https://example.com',
+ source: 'goo&gle',
+ })
+ expect(result.valid).toBe(false)
+ expect(result.errors.some((e) => e.includes('source'))).toBe(true)
+ })
+
+ it('returns errors for multiple unsafe params', () => {
+ const result = buildUtmUrl({
+ url: 'https://example.com',
+ source: 'goo=gle',
+ medium: 'cp?c',
+ })
+ expect(result.valid).toBe(false)
+ expect(result.errors.length).toBeGreaterThanOrEqual(2)
+ })
+ })
+
+ describe('warnings', () => {
+ it('warns about uppercase characters in values', () => {
+ const result = buildUtmUrl({
+ url: 'https://example.com',
+ source: 'Google',
+ })
+ expect(result.valid).toBe(true)
+ expect(result.warnings.some((w) => w.includes('uppercase'))).toBe(true)
+ })
+
+ it('no warnings for lowercase values', () => {
+ const result = buildUtmUrl({
+ url: 'https://example.com',
+ source: 'google',
+ })
+ expect(result.warnings).toEqual([])
+ })
+ })
+
+ describe('options', () => {
+ it('lowercases values when lowercaseValues is true', () => {
+ const result = buildUtmUrl(
+ { url: 'https://example.com', source: 'Google', campaign: 'Spring2025' },
+ { lowercaseValues: true },
+ )
+ expect(result.url).toContain('utm_source=google')
+ expect(result.url).toContain('utm_campaign=spring2025')
+ expect(result.warnings).toEqual([])
+ })
+
+ it('skips URL validation when validate is false', () => {
+ const result = buildUtmUrl(
+ { url: 'anything', source: 'google' },
+ { validate: false, normalize: false },
+ )
+ // Should still try to build (may fail at URL construction)
+ expect(result.errors.every((e) => e !== 'url is invalid')).toBe(true)
+ })
+
+ it('fires onAppend callback with final URL and params', () => {
+ const onAppend = vi.fn()
+ const result = buildUtmUrl({ url: 'https://example.com', source: 'google' }, { onAppend })
+ expect(onAppend).toHaveBeenCalledOnce()
+ expect(onAppend).toHaveBeenCalledWith(
+ result.url,
+ expect.objectContaining({ utm_source: 'google' }),
+ )
+ })
+
+ it('does not fire onAppend when build fails', () => {
+ const onAppend = vi.fn()
+ buildUtmUrl({ url: 'https://example.com', source: '' }, { onAppend })
+ expect(onAppend).not.toHaveBeenCalled()
+ })
+ })
+})
+
+describe('validateUtmValues', () => {
+ it('returns no errors for valid values', () => {
+ const result = validateUtmValues({ source: 'google', medium: 'cpc' })
+ expect(result.errors).toEqual([])
+ })
+
+ it('returns errors for unsafe characters', () => {
+ const result = validateUtmValues({ source: 'goo&gle' })
+ expect(result.errors.length).toBeGreaterThan(0)
+ })
+
+ it('returns warnings for uppercase values', () => {
+ const result = validateUtmValues({ source: 'Google' })
+ expect(result.warnings.some((w) => w.includes('uppercase'))).toBe(true)
+ })
+
+ it('handles empty/undefined values gracefully', () => {
+ const result = validateUtmValues({})
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ })
+})
diff --git a/__tests__/outbound/decorator.test.ts b/__tests__/outbound/decorator.test.ts
new file mode 100644
index 0000000..33ba3c9
--- /dev/null
+++ b/__tests__/outbound/decorator.test.ts
@@ -0,0 +1,151 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { decorateLinks, observeAndDecorateLinks } from '../../src/outbound/decorator'
+import { storeUtmParameters } from '../../src/common/storage'
+
+function createLink(href: string, parent?: HTMLElement): HTMLAnchorElement {
+ const a = document.createElement('a')
+ a.href = href
+ ;(parent ?? document.body).appendChild(a)
+ return a
+}
+
+describe('decorateLinks', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ document.body.replaceChildren()
+ })
+
+ it('appends stored UTM params to links on the page', () => {
+ storeUtmParameters({ utm_source: 'google', utm_medium: 'cpc' })
+ createLink('https://example.com/page')
+ const count = decorateLinks()
+ expect(count).toBe(1)
+ const link = document.querySelector('a') as HTMLAnchorElement
+ expect(link.href).toContain('utm_source=google')
+ expect(link.href).toContain('utm_medium=cpc')
+ })
+
+ it('returns 0 when no params stored', () => {
+ createLink('https://example.com')
+ const count = decorateLinks()
+ expect(count).toBe(0)
+ })
+
+ it('returns 0 when no links on page', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const count = decorateLinks()
+ expect(count).toBe(0)
+ })
+
+ describe('host filtering', () => {
+ it('only decorates internal links by default', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ // jsdom default location is https://example.com
+ createLink('https://example.com/page1')
+ createLink('https://other-site.com/page2')
+ const count = decorateLinks()
+ expect(count).toBe(1)
+ const links = document.querySelectorAll('a')
+ expect(links[0].href).toContain('utm_source=google')
+ expect(links[1].href).not.toContain('utm_source')
+ })
+
+ it('decorates all links when internalOnly is false', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com/page')
+ createLink('https://other-site.com/page')
+ const count = decorateLinks({ internalOnly: false })
+ expect(count).toBe(2)
+ })
+
+ it('includes additional hosts when specified', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com/page')
+ createLink('https://partner.com/page')
+ createLink('https://other.com/page')
+ const count = decorateLinks({ includeHosts: ['partner.com'] })
+ expect(count).toBe(2)
+ })
+
+ it('excludes specified hosts', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com/page1')
+ createLink('https://example.com/page2')
+ const count = decorateLinks({
+ internalOnly: false,
+ excludeHosts: ['example.com'],
+ })
+ expect(count).toBe(0)
+ })
+ })
+
+ describe('skipExisting', () => {
+ it('skips links that already have UTM params by default', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com?utm_source=existing')
+ const count = decorateLinks()
+ expect(count).toBe(0)
+ const link = document.querySelector('a') as HTMLAnchorElement
+ expect(link.href).toContain('utm_source=existing')
+ expect(link.href).not.toContain('utm_source=google')
+ })
+
+ it('decorates links with existing UTMs when skipExisting is false', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com?utm_source=existing')
+ const count = decorateLinks({ skipExisting: false })
+ expect(count).toBe(1)
+ })
+ })
+
+ describe('options', () => {
+ it('uses custom CSS selector', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const a1 = createLink('https://example.com/page1')
+ a1.className = 'track'
+ createLink('https://example.com/page2')
+ const count = decorateLinks({ selector: 'a.track' })
+ expect(count).toBe(1)
+ })
+
+ it('appends extra static params', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com/page')
+ decorateLinks({ extraParams: { utm_campaign: 'spring' } })
+ const link = document.querySelector('a') as HTMLAnchorElement
+ expect(link.href).toContain('utm_source=google')
+ expect(link.href).toContain('utm_campaign=spring')
+ })
+
+ it('fires onAppend callback for each decorated link', () => {
+ const onAppend = vi.fn()
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com/page')
+ decorateLinks({ onAppend })
+ expect(onAppend).toHaveBeenCalledOnce()
+ })
+ })
+})
+
+describe('observeAndDecorateLinks', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ document.body.replaceChildren()
+ })
+
+ it('returns a cleanup function', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const cleanup = observeAndDecorateLinks()
+ expect(typeof cleanup).toBe('function')
+ cleanup()
+ })
+
+ it('decorates existing links immediately', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ createLink('https://example.com/page')
+ observeAndDecorateLinks()
+ const link = document.querySelector('a') as HTMLAnchorElement
+ expect(link.href).toContain('utm_source=google')
+ })
+})
diff --git a/__tests__/react/UtmHiddenFields.test.tsx b/__tests__/react/UtmHiddenFields.test.tsx
new file mode 100644
index 0000000..557c111
--- /dev/null
+++ b/__tests__/react/UtmHiddenFields.test.tsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import React from 'react'
+import { render } from '@testing-library/react'
+import { UtmHiddenFields } from '../../src/react/UtmHiddenFields'
+import { useUtmFormData } from '../../src/react/useUtmFormData'
+import { renderHook } from '@testing-library/react'
+import { storeUtmParameters } from '../../src/common/storage'
+
+describe('UtmHiddenFields', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ })
+
+ it('renders hidden inputs for stored UTM params', () => {
+ storeUtmParameters({ utm_source: 'google', utm_medium: 'cpc' })
+ const { container } = render()
+ const inputs = container.querySelectorAll('input[type="hidden"]')
+ expect(inputs.length).toBe(2)
+ })
+
+ it('sets correct name and value attributes', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const { container } = render()
+ const input = container.querySelector('input[name="utm_source"]') as HTMLInputElement
+ expect(input).not.toBeNull()
+ expect(input.value).toBe('google')
+ })
+
+ it('renders nothing when no params stored', () => {
+ const { container } = render()
+ const inputs = container.querySelectorAll('input')
+ expect(inputs.length).toBe(0)
+ })
+
+ it('supports custom prefix for field names', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const { container } = render()
+ const input = container.querySelector('input[name="tracking_utm_source"]') as HTMLInputElement
+ expect(input).not.toBeNull()
+ expect(input.value).toBe('google')
+ })
+})
+
+describe('useUtmFormData', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ })
+
+ it('returns flat key-value record of UTM params', () => {
+ storeUtmParameters({ utm_source: 'google', utm_medium: 'cpc' })
+ const { result } = renderHook(() => useUtmFormData())
+ expect(result.current).toEqual({ utm_source: 'google', utm_medium: 'cpc' })
+ })
+
+ it('returns empty object when no params stored', () => {
+ const { result } = renderHook(() => useUtmFormData())
+ expect(result.current).toEqual({})
+ })
+})
diff --git a/__tests__/react/UtmLinkDecorator.test.tsx b/__tests__/react/UtmLinkDecorator.test.tsx
new file mode 100644
index 0000000..d1ada28
--- /dev/null
+++ b/__tests__/react/UtmLinkDecorator.test.tsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import React from 'react'
+import { render } from '@testing-library/react'
+import { renderHook } from '@testing-library/react'
+import { UtmLinkDecorator, useUtmLinkDecorator } from '../../src/react/UtmLinkDecorator'
+import { storeUtmParameters } from '../../src/common/storage'
+
+describe('UtmLinkDecorator', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ document.body.replaceChildren()
+ })
+
+ it('decorates child links on mount', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const { container } = render(
+
+ Link
+ ,
+ )
+ const link = container.querySelector('a') as HTMLAnchorElement
+ expect(link.href).toContain('utm_source=google')
+ })
+
+ it('does not decorate when no params stored', () => {
+ const { container } = render(
+
+ Link
+ ,
+ )
+ const link = container.querySelector('a') as HTMLAnchorElement
+ expect(link.href).not.toContain('utm_source')
+ })
+})
+
+describe('useUtmLinkDecorator', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ document.body.replaceChildren()
+ })
+
+ it('returns a ref object', () => {
+ storeUtmParameters({ utm_source: 'google' })
+ const { result } = renderHook(() => useUtmLinkDecorator())
+ expect(result.current).toBeDefined()
+ expect(result.current).toHaveProperty('current')
+ })
+})
diff --git a/src/common/docs.md b/src/common/docs.md
new file mode 100644
index 0000000..a8b112e
--- /dev/null
+++ b/src/common/docs.md
@@ -0,0 +1,42 @@
+# Noridoc: common
+
+Path: @/src/common
+
+### Overview
+
+- Shared utilities used by both `@/src/inbound` and `@/src/outbound` pathways: browser storage persistence, UTM key format conversion, and URL validation.
+- All exports are re-exported through `@/src/index.ts` to package consumers.
+- Framework-agnostic and SSR-safe throughout.
+
+### How it fits into the larger codebase
+
+- `@/src/inbound` imports storage functions for persisting captured UTM params, key functions for format detection and conversion, and validator for URL checks.
+- `@/src/outbound` imports storage for reading stored params (in decorator and builder), key functions for format conversion (in appender), and validator for URL normalization/validation (in builder and appender).
+- `@/src/react` imports storage functions, key conversion, and config-based options directly from this module.
+- `@/src/debug` imports `getStoredUtmParameters`, `isStorageAvailable`, and `getRawStoredValue` from storage.
+- Types (`KeyFormat`, `StorageType`, `UtmParameters`) come from `@/src/types`.
+
+### Core Implementation
+
+**Storage (`storage.ts`)** manages persistence in sessionStorage or localStorage:
+
+- Data is stored in an **envelope format**: `{ params: UtmParameters, iat: number, eat: number | null }` where `iat` is issued-at timestamp and `eat` is expires-at (null means no expiry).
+- `getStorageBackend(type)` is a private helper that returns the `Storage` object (sessionStorage or localStorage) after verifying it is functional via a test write/read. Returns null if unavailable.
+- `storeUtmParameters()` converts params to the target key format, computes `eat` from TTL (only for localStorage; TTL is ignored for sessionStorage), wraps in an envelope, and writes to storage. Fires optional `onStore` callback in a try-catch.
+- `getStoredUtmParameters()` reads and parses stored data. Detects whether data is in envelope format (via `isEnvelopeFormat()` which checks for `params`, `iat`, and `eat` fields) or flat format (backward compatibility). For envelopes, checks TTL expiration and auto-clears expired data, firing optional `onExpire` callback. For flat format data, validates and returns directly.
+- `clearStoredUtmParameters()` has an overloaded signature: accepts either a `ClearOptions` object or positional `(storageKey, storageType)` arguments for backward compatibility. Fires optional `onClear` callback.
+- `isStorageAvailable(type)` is the public availability check. `isSessionStorageAvailable()` is deprecated in favor of `isStorageAvailable('session')`. `isLocalStorageAvailable()` is a convenience wrapper.
+
+**Keys (`keys.ts`)** handles bidirectional conversion between `snake_case` and `camelCase` UTM key formats. Maintains lookup maps (`SNAKE_TO_CAMEL`, `CAMEL_TO_SNAKE`) for the 6 standard UTM params and handles custom `utm_*` keys via algorithmic conversion.
+
+**Validator (`validator.ts`)** provides URL validation and normalization. Checks protocol allowlists, domain validity, and adds protocols when missing. Has module-level mutable state for the default protocol (see CLAUDE.md gotchas).
+
+### Things to Know
+
+- **Envelope backward compatibility**: `isEnvelopeFormat()` requires all three fields (`params`, `iat`, `eat`) with correct types. Data stored by older library versions (flat `UtmParameters` objects without an envelope) is handled as a fallback path in `getStoredUtmParameters()`.
+- **TTL is only meaningful for localStorage**: When `storageType` is `'session'`, `eat` is always set to `null` regardless of `ttl` value, because sessionStorage already expires on tab/browser close.
+- **TTL uses explicit type check**: The TTL computation uses `typeof ttl === 'number' && ttl > 0` rather than a truthy check, to correctly handle the edge case where `ttl === 0`.
+- **All callbacks are try-catch wrapped**: `onStore`, `onExpire`, and `onClear` callbacks are individually wrapped so a throwing callback never breaks the storage pipeline.
+- **`validator.ts` has mutable module-level state**: `defaultProtocol` is mutable via `setDefaultProtocol()`. Tests that call this must restore the original value.
+
+Created and maintained by Nori.
diff --git a/src/core/index.ts b/src/common/index.ts
similarity index 63%
rename from src/core/index.ts
rename to src/common/index.ts
index 5bd12a3..ad64bf6 100644
--- a/src/core/index.ts
+++ b/src/common/index.ts
@@ -1,18 +1,9 @@
/**
- * Core exports
+ * Common exports
*
- * Framework-agnostic utilities for UTM parameter management.
+ * Shared utilities used by both inbound and outbound pathways.
*/
-// Capture utilities
-export {
- captureUtmParameters,
- hasUtmParameters,
- captureFromCurrentUrl,
- captureWithReferrer,
- type CaptureOptions,
-} from './capture'
-
// Storage utilities
export {
storeUtmParameters,
@@ -25,11 +16,9 @@ export {
getRawStoredValue,
DEFAULT_STORAGE_KEY,
type StorageOptions,
+ type ClearOptions,
} from './storage'
-// Appender utilities
-export { appendUtmParameters, removeUtmParameters, extractUtmParameters } from './appender'
-
// Key conversion utilities
export {
toSnakeCase,
@@ -50,12 +39,6 @@ export {
STANDARD_CAMEL_KEYS,
} from './keys'
-// Sanitizer utilities
-export { sanitizeValue, sanitizeParams } from './sanitizer'
-
-// PII filter utilities
-export { detectPii, filterValue, filterParams } from './pii-filter'
-
// Validator utilities
export {
validateUrl,
diff --git a/src/core/keys.ts b/src/common/keys.ts
similarity index 100%
rename from src/core/keys.ts
rename to src/common/keys.ts
diff --git a/src/core/storage.ts b/src/common/storage.ts
similarity index 82%
rename from src/core/storage.ts
rename to src/common/storage.ts
index c5d2b3d..0413012 100644
--- a/src/core/storage.ts
+++ b/src/common/storage.ts
@@ -29,6 +29,12 @@ export interface StorageOptions {
/** TTL in milliseconds (only applies to localStorage, ignored for sessionStorage) */
ttl?: number
+
+ /** Fired after UTM params are written to storage */
+ onStore?: (params: UtmParameters, meta: { storageType: StorageType }) => void
+
+ /** Fired when stored params expire (TTL) and are auto-cleaned */
+ onExpire?: (storageKey: string) => void
}
/**
@@ -174,6 +180,7 @@ export function storeUtmParameters(params: UtmParameters, options: StorageOption
keyFormat = 'snake_case',
storageType = 'session',
ttl,
+ onStore,
} = options
const storage = getStorageBackend(storageType)
@@ -201,6 +208,14 @@ export function storeUtmParameters(params: UtmParameters, options: StorageOption
const serialized = JSON.stringify(envelope)
storage.setItem(storageKey, serialized)
+
+ if (onStore) {
+ try {
+ onStore(paramsToStore, { storageType })
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
} catch (error) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('Failed to store UTM parameters:', error)
@@ -220,7 +235,7 @@ export function storeUtmParameters(params: UtmParameters, options: StorageOption
* @returns Stored UTM parameters or null if not found/invalid/expired
*/
export function getStoredUtmParameters(options: StorageOptions = {}): UtmParameters | null {
- const { storageKey = DEFAULT_STORAGE_KEY, keyFormat, storageType = 'session' } = options
+ const { storageKey = DEFAULT_STORAGE_KEY, keyFormat, storageType = 'session', onExpire } = options
const storage = getStorageBackend(storageType)
if (!storage) {
@@ -246,6 +261,13 @@ export function getStoredUtmParameters(options: StorageOptions = {}): UtmParamet
} catch {
// Ignore cleanup errors
}
+ if (onExpire) {
+ try {
+ onExpire(storageKey)
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
return null
}
@@ -283,16 +305,45 @@ export function getStoredUtmParameters(options: StorageOptions = {}): UtmParamet
}
}
+/**
+ * Options for clearing stored UTM parameters
+ */
+export interface ClearOptions {
+ /** Storage key to clear (default: 'utm_parameters') */
+ storageKey?: string
+ /** Storage backend to clear from (default: 'session') */
+ storageType?: StorageType
+ /** Fired when stored params are cleared */
+ onClear?: () => void
+}
+
/**
* Removes stored UTM parameters from browser storage
*
- * @param storageKey - Storage key to clear (default: 'utm_parameters')
- * @param storageType - Storage backend to clear from (default: 'session')
+ * @param options - Clear options including key, type, and callback
*/
+export function clearStoredUtmParameters(options?: ClearOptions): void
+/**
+ * @deprecated Use clearStoredUtmParameters(options) instead
+ */
+export function clearStoredUtmParameters(storageKey?: string, storageType?: StorageType): void
export function clearStoredUtmParameters(
- storageKey: string = DEFAULT_STORAGE_KEY,
- storageType: StorageType = 'session',
+ storageKeyOrOptions?: string | ClearOptions,
+ storageTypeArg?: StorageType,
): void {
+ let storageKey = DEFAULT_STORAGE_KEY
+ let storageType: StorageType = 'session'
+ let onClear: (() => void) | undefined
+
+ if (typeof storageKeyOrOptions === 'object' && storageKeyOrOptions !== null) {
+ storageKey = storageKeyOrOptions.storageKey ?? DEFAULT_STORAGE_KEY
+ storageType = storageKeyOrOptions.storageType ?? 'session'
+ onClear = storageKeyOrOptions.onClear
+ } else {
+ storageKey = storageKeyOrOptions ?? DEFAULT_STORAGE_KEY
+ storageType = storageTypeArg ?? 'session'
+ }
+
const storage = getStorageBackend(storageType)
if (!storage) {
return
@@ -300,6 +351,13 @@ export function clearStoredUtmParameters(
try {
storage.removeItem(storageKey)
+ if (onClear) {
+ try {
+ onClear()
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
} catch (error) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('Failed to clear UTM parameters:', error)
diff --git a/src/core/validator.ts b/src/common/validator.ts
similarity index 100%
rename from src/core/validator.ts
rename to src/common/validator.ts
diff --git a/src/config/defaults.ts b/src/config/defaults.ts
index 93d424c..ddb3739 100644
--- a/src/config/defaults.ts
+++ b/src/config/defaults.ts
@@ -4,7 +4,23 @@
* Provides sensible defaults for UTM toolkit configuration.
*/
-import type { PiiFilterConfig, PiiPattern, ResolvedUtmConfig, SanitizeConfig } from '../types'
+import type {
+ AttributionConfig,
+ PiiFilterConfig,
+ PiiPattern,
+ ResolvedUtmConfig,
+ SanitizeConfig,
+} from '../types'
+
+/**
+ * Default attribution configuration
+ * Last-touch only by default (preserves existing behavior)
+ */
+export const DEFAULT_ATTRIBUTION_CONFIG: AttributionConfig = {
+ mode: 'last',
+ firstTouchSuffix: '_first',
+ lastTouchSuffix: '_last',
+}
/**
* Default sanitization configuration
@@ -105,6 +121,9 @@ export const DEFAULT_CONFIG: ResolvedUtmConfig = {
/** PII filtering disabled by default (deep copy to prevent shared references) */
piiFiltering: { ...DEFAULT_PII_FILTER_CONFIG, patterns: [...DEFAULT_PII_PATTERNS] },
+
+ /** Last-touch attribution by default */
+ attribution: { ...DEFAULT_ATTRIBUTION_CONFIG },
}
/**
@@ -123,5 +142,6 @@ export function getDefaultConfig(): ResolvedUtmConfig {
...DEFAULT_CONFIG.piiFiltering,
patterns: DEFAULT_CONFIG.piiFiltering.patterns.map((p) => ({ ...p })),
},
+ attribution: { ...DEFAULT_CONFIG.attribution },
}
}
diff --git a/src/config/docs.md b/src/config/docs.md
index 62284d6..512a52f 100644
--- a/src/config/docs.md
+++ b/src/config/docs.md
@@ -12,23 +12,25 @@ Path: @/src/config
- `createConfig()` is the primary entry point, called by `useUtmTracking` in `@/src/react` to resolve user-provided partial config into a complete `ResolvedUtmConfig`.
- `@/src/debug` imports `getDefaultConfig()` from here as a fallback when no config is provided to diagnostic functions.
-- `DEFAULT_CONFIG` and `STANDARD_UTM_PARAMETERS` are the canonical definitions of default behavior (enabled, snake_case, `storageType: 'session'`, sessionStorage key `utm_parameters`, no TTL, auto-capture on mount, append to shares, the 6 standard UTM params).
-- `DEFAULT_SANITIZE_CONFIG` defines the sanitization defaults: disabled by default, but with safe-by-default values when enabled (`stripHtml: true`, `stripControlChars: true`, `maxLength: 200`). It is exported as a public constant and spread into `DEFAULT_CONFIG.sanitize`.
-- `DEFAULT_PII_PATTERNS` defines built-in PII detection regexes (email, phone_international, phone_uk, phone_us), all enabled by default. `DEFAULT_PII_FILTER_CONFIG` wraps these patterns with `enabled: false` and `mode: 'reject'`. Both are exported as public constants and used in `DEFAULT_CONFIG.piiFiltering`.
+- `DEFAULT_CONFIG` and `STANDARD_UTM_PARAMETERS` are the canonical definitions of default behavior (enabled, snake_case, `storageType: 'session'`, sessionStorage key `utm_parameters`, no TTL, attribution mode `'last'`, auto-capture on mount, append to shares, the 6 standard UTM params).
+- `DEFAULT_ATTRIBUTION_CONFIG` defines attribution defaults: `mode: 'last'` with suffixes `_first` and `_last`. This preserves existing last-touch-only behavior when attribution is not explicitly configured.
+- `DEFAULT_SANITIZE_CONFIG` defines the sanitization defaults: disabled by default, but with safe-by-default values when enabled. `DEFAULT_PII_PATTERNS` and `DEFAULT_PII_FILTER_CONFIG` define PII detection defaults.
+- Event callbacks (`onCapture`, `onStore`, `onClear`, `onAppend`, `onExpire`) are passed through from user config via `createConfig()` and `mergeConfig()` -- they have no defaults (undefined when not provided).
- The config system does not perform side effects -- it is pure data transformation.
### Core Implementation
-- `createConfig()` merges a partial user config with defaults using nullish coalescing (`??`) for scalar fields, including `storageType` and `ttl`. Array fields (`allowedParameters`, `excludeFromShares`) are replaced wholesale when provided by the user, not merged. Object fields (`defaultParams`, `shareContextParams`) are shallow-merged. The `sanitize` field is merged via `mergeSanitizeConfig()` and `piiFiltering` via `mergePiiFilterConfig()`, both using nullish coalescing per-field so partial overrides preserve unspecified defaults. For `piiFiltering`, user-provided `patterns` replace the defaults entirely (array replacement semantics), while scalar fields like `enabled` and `mode` merge individually.
-- `mergeConfig()` follows the same semantics but takes a `ResolvedUtmConfig` as the base instead of implicitly using defaults -- useful for layering configurations.
+- `createConfig()` merges a partial user config with defaults using nullish coalescing (`??`) for scalar fields, including `storageType`, `ttl`, and event callbacks. Array fields (`allowedParameters`, `excludeFromShares`) are replaced wholesale when provided by the user, not merged. Object fields (`defaultParams`, `shareContextParams`) are shallow-merged. Nested config objects (`sanitize`, `piiFiltering`, `attribution`) each have dedicated merge functions that apply nullish coalescing per-field so partial overrides preserve unspecified defaults.
+- `mergeAttributionConfig()` merges `mode`, `firstTouchSuffix`, and `lastTouchSuffix` with nullish coalescing, following the same pattern as other nested configs.
+- `mergeConfig()` follows the same semantics but takes a `ResolvedUtmConfig` as the base instead of implicitly using defaults -- useful for layering configurations. It also forwards event callbacks with nullish coalescing.
- `loadConfigFromJson()` accepts `unknown` input, validates it is a non-null non-array object, then delegates to `createConfig()`. Invalid input falls back to defaults with a `console.warn`.
-- `validateConfig()` performs runtime type checking on each config field and returns an array of error message strings (empty array means valid). It validates `storageType` as `'session'` or `'local'`, and `ttl` as a positive finite number. Sanitize validation checks that `sanitize` is an object, `enabled`/`stripHtml`/`stripControlChars` are booleans, `maxLength` is a positive finite number, and `customPattern` is a RegExp. PII filtering validation checks that `piiFiltering` is an object, `enabled` is boolean, `mode` is `'reject'` or `'redact'`, and `patterns` is an array.
-- `getDefaultConfig()` returns a shallow copy of `DEFAULT_CONFIG` with cloned arrays and objects to prevent mutation of the shared constant. For `piiFiltering`, it deep-copies each pattern object (`patterns.map(p => ({...p}))`) since patterns contain RegExp references that should not be shared.
+- `validateConfig()` performs runtime type checking on each config field and returns an array of error message strings (empty array means valid). It validates `storageType` as `'session'` or `'local'`, `ttl` as a positive finite number, plus nested validation for `sanitize` and `piiFiltering` sub-objects.
+- `getDefaultConfig()` returns a shallow copy of `DEFAULT_CONFIG` with cloned arrays and objects (including deep-copied PII patterns and attribution config) to prevent mutation of the shared constant.
### Things to Know
- Array replacement (not merge) for `allowedParameters` is intentional: if a consumer provides `allowedParameters: ['utm_source']`, they get only that parameter, not the union with defaults. This is a deliberate design choice.
-- `STANDARD_UTM_PARAMETERS` is declared `as const` and used both as the default `allowedParameters` value and as the source of truth in tests. It defines the 6 standard UTM params: source, medium, campaign, term, content, id.
+- `STANDARD_UTM_PARAMETERS` is declared `as const` and used both as the default `allowedParameters` value and as the source of truth in tests.
- `validateConfig()` and `createConfig()` are independent -- `createConfig()` does not call `validateConfig()`. Validation is opt-in for consumers who want to check config before using it.
Created and maintained by Nori.
diff --git a/src/config/index.ts b/src/config/index.ts
index dd7cb5f..ca0f968 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -7,6 +7,7 @@ export {
DEFAULT_SANITIZE_CONFIG,
DEFAULT_PII_PATTERNS,
DEFAULT_PII_FILTER_CONFIG,
+ DEFAULT_ATTRIBUTION_CONFIG,
STANDARD_UTM_PARAMETERS,
getDefaultConfig,
} from './defaults'
diff --git a/src/config/loader.ts b/src/config/loader.ts
index a20b12b..624e983 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -5,6 +5,7 @@
*/
import type {
+ AttributionConfig,
UtmConfig,
ResolvedUtmConfig,
PiiFilterConfig,
@@ -131,6 +132,29 @@ export function createConfig(userConfig?: Partial): ResolvedUtmConfig
: defaults.excludeFromShares,
sanitize: mergeSanitizeConfig(defaults.sanitize, userConfig.sanitize),
piiFiltering: mergePiiFilterConfig(defaults.piiFiltering, userConfig.piiFiltering),
+ attribution: mergeAttributionConfig(defaults.attribution, userConfig.attribution),
+ onCapture: userConfig.onCapture,
+ onStore: userConfig.onStore,
+ onClear: userConfig.onClear,
+ onAppend: userConfig.onAppend,
+ onExpire: userConfig.onExpire,
+ }
+}
+
+/**
+ * Merge attribution config with defaults
+ */
+function mergeAttributionConfig(
+ base: AttributionConfig,
+ override: Partial | undefined,
+): AttributionConfig {
+ if (!override) {
+ return { ...base }
+ }
+ return {
+ mode: override.mode ?? base.mode,
+ firstTouchSuffix: override.firstTouchSuffix ?? base.firstTouchSuffix,
+ lastTouchSuffix: override.lastTouchSuffix ?? base.lastTouchSuffix,
}
}
@@ -166,6 +190,12 @@ export function mergeConfig(
: [...base.excludeFromShares],
sanitize: mergeSanitizeConfig(base.sanitize, override.sanitize),
piiFiltering: mergePiiFilterConfig(base.piiFiltering, override.piiFiltering),
+ attribution: mergeAttributionConfig(base.attribution, override.attribution),
+ onCapture: override.onCapture ?? base.onCapture,
+ onStore: override.onStore ?? base.onStore,
+ onClear: override.onClear ?? base.onClear,
+ onAppend: override.onAppend ?? base.onAppend,
+ onExpire: override.onExpire ?? base.onExpire,
}
}
diff --git a/src/core/docs.md b/src/core/docs.md
deleted file mode 100644
index 8eaf054..0000000
--- a/src/core/docs.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Noridoc: core
-
-Path: @/src/core
-
-### Overview
-
-- Framework-agnostic core logic for capturing UTM parameters from URLs, persisting them in browser storage (sessionStorage or localStorage with optional TTL), appending them to outbound URLs, converting between key formats, and validating URLs.
-- This is the heart of the library. Every other module (`@/src/react`, `@/src/debug`) builds on top of these utilities.
-- All functions are SSR-safe, returning empty/null/unchanged values when browser APIs are unavailable.
-
-### How it fits into the larger codebase
-
-- `@/src/react/useUtmTracking.ts` orchestrates the core modules: it calls `captureUtmParameters` on mount, `storeUtmParameters`/`getStoredUtmParameters` for persistence, `appendUtmParameters` for URL generation, and `convertParams`/`isSnakeCaseUtmKey` for format handling.
-- `@/src/debug` imports from `capture` and `storage` to assemble diagnostic snapshots.
-- `@/src/index.ts` re-exports everything from this module for direct consumer use without React.
-- All modules import types from `@/src/types`.
-
-### Core Implementation
-
-The data flow through the core modules follows this path:
-
-```text
-URL string
- |
- v
-[capture.ts] -- parses URL, filters to utm_* keys, applies allowedParameters, sanitizes values, filters PII, converts key format
- |
- v
-UtmParameters object
- |
- v
-[storage.ts] -- wraps in envelope {params, iat, eat}, serializes to JSON, writes/reads sessionStorage or localStorage, checks TTL on read, validates on read
- |
- v
-[appender.ts] -- converts params to snake_case, merges into target URL query/fragment
- |
- v
-URL string with UTM params
-```
-
-- **keys.ts**: Bidirectional key conversion between `snake_case` and `camelCase`. Uses lookup tables (`SNAKE_TO_CAMEL`, `CAMEL_TO_SNAKE`) for the 6 standard keys and regex-based conversion for custom keys. `isSnakeCaseUtmKey` checks for `utm_` prefix; `isCamelCaseUtmKey` checks for `utm` followed by an uppercase letter. `detectKeyFormat` scans keys and returns the first format found, defaulting to `snake_case` for empty objects.
-
-- **capture.ts**: `captureUtmParameters()` takes a URL string (defaulting to `window.location.href`), parses it via `new URL()`, iterates `searchParams`, and filters to keys passing `isSnakeCaseUtmKey`. The pipeline order is: extract params --> filter by allowlist --> sanitize --> PII filter --> convert key format. Both sanitization and PII filtering resolve their config by spreading user-provided partial config over the corresponding `DEFAULT_*` constants from `@/src/config/defaults.ts`, then check `enabled` before running.
-
-- **sanitizer.ts**: `sanitizeValue()` strips dangerous characters from a single string value. Rules apply in order: HTML-significant characters (`< > " ' \``) --> control characters (\x00-\x1F except tab/newline/CR) --> optional custom regex pattern --> trim --> truncate to `maxLength`. `sanitizeParams()` applies `sanitizeValue()` to every non-undefined value in a `UtmParameters` object, returning a new object with keys preserved unchanged. Both functions are pure and stateless; all behavior is driven by the `SanitizeConfig` argument.
-
-- **pii-filter.ts**: `detectPii()` tests a value against an array of `PiiPattern` objects and returns the first match (or null). `filterValue()` checks a single value: if an `allowlistPattern` is configured, the value must match it to pass (allowlist takes precedence over pattern detection); otherwise, it falls back to `detectPii()`. In `reject` mode, detected PII causes the value to be dropped (returns `undefined`); in `redact` mode, the value is replaced with `'[REDACTED]'`. `filterParams()` applies `filterValue()` to every non-undefined value, omitting keys entirely in reject mode when PII is found. The optional `onPiiDetected` callback fires synchronously with `(key, value, patternName)`.
-
-- **storage.ts**: Supports both sessionStorage and localStorage backends, selected via `StorageType` (`'session'` | `'local'`). All stored data uses an envelope format `{ params, iat, eat }` where `iat` is the "issued at" timestamp and `eat` is the "expires at" timestamp (or `null` for no expiry). TTL is only meaningful for localStorage; sessionStorage always stores `eat: null` since session lifetime handles expiry. On read, expired data (where `Date.now() > eat`) is auto-cleared from storage and returns `null` (lazy expiration, no background timers). Backward compatibility is maintained: if stored data is in the old flat format (pre-envelope, just a plain `UtmParameters` object), it is detected by `isEnvelopeFormat()` and read without TTL checking. Write operations skip empty param objects and fail silently with `console.warn`. Read operations validate parsed JSON with `isValidStoredData()`, which checks that all keys pass `isUtmKey` and all values are strings or undefined. The `getStorageBackend()` internal function selects the correct `Storage` object and verifies it is functional via a write/read test. Three availability-check functions are exported: `isStorageAvailable(type)` (the primary generic check), `isSessionStorageAvailable()` (deprecated alias), and `isLocalStorageAvailable()`.
-
-- **appender.ts**: `appendUtmParameters()` always converts input params to snake_case before appending to URLs (URL parameters are conventionally snake_case). Supports query string or fragment placement via `AppendOptions.toFragment`. Uses a custom `buildQueryString()` that omits `=` for empty-string values. When adding to query, it also cleans conflicting UTM params from the fragment (and vice versa). `removeUtmParameters()` strips UTM params from both query and fragment. `extractUtmParameters()` pulls UTM params from both locations, with fragment params taking precedence.
-
-- **validator.ts**: `validateUrl()` checks protocol (http/https only), domain (must contain a `.` for TLD), and parsability. `normalizeUrl()` prepends a configurable default protocol (module-level `let defaultProtocol`). `setDefaultProtocol()` mutates this module-level state.
-
-### Things to Know
-
-- **Key invariant**: All URL-facing operations use snake_case keys. The `appender` always converts to snake_case before manipulating URLs, regardless of what format the consumer passes in. This means URLs always contain `utm_source`, never `utmSource`.
-- **SSR safety pattern**: Each module that accesses browser APIs (`window`, `sessionStorage`, `URL`, `document`) checks for their existence before use and returns a safe fallback (empty object, null, or unchanged URL). This is consistent across all core modules.
-- **Silent failure**: Storage and capture operations never throw. Errors produce `console.warn` messages and return fallback values. The appender returns the original URL unchanged on failure.
-- **Storage envelope format**: All new writes use the envelope format `{ params, iat, eat }`, even for sessionStorage (where `eat` is always `null`). This consistency simplifies the read path -- `isEnvelopeFormat()` detects the format, then TTL checking applies uniformly. Old flat-format data is still readable for backward compatibility but will never be written.
-- **Lazy TTL expiration**: There is no background timer or polling. Expired data is only detected and cleared when `getStoredUtmParameters()` is called. This means expired data can sit in localStorage until the next read.
-- **TTL is silently ignored for sessionStorage**: If a consumer passes `ttl` with `storageType: 'session'`, the TTL value is not stored (`eat: null`). Session lifetime handles expiry instead.
-- **validator.ts mutable state**: `defaultProtocol` is module-level mutable state modified via `setDefaultProtocol()`. This is global -- all callers share the same default protocol. Tests that call `setDefaultProtocol()` should restore the original value.
-- **Fragment parameter handling in appender**: When appending to query, conflicting UTM params are removed from the fragment. When appending to fragment, conflicting UTM params are removed from the query. Only fragments that contain `=` are treated as parameter-bearing; plain anchors like `#section` are left alone.
-- **Sanitization and PII filtering are capture-time only**: Both run during `captureUtmParameters()` before values enter the system. They do not run at storage time, append time, or on read. Values stored in sessionStorage are already sanitized/filtered if these features were enabled at capture.
-- **PII filter runs after sanitization**: This ordering matters because sanitization may strip characters (e.g., HTML angle brackets) that could affect whether a PII regex matches. By sanitizing first, PII detection operates on the cleaned value.
-- **Regex `lastIndex` reset**: Both `sanitizer.ts` (for `customPattern`) and `pii-filter.ts` (for each `PiiPattern.pattern` and `allowlistPattern`) reset `lastIndex = 0` before calling `.test()` or `.replace()`. This prevents stale state when a regex with the `g` flag is reused across calls.
-
-Created and maintained by Nori.
diff --git a/src/debug/docs.md b/src/debug/docs.md
index 654a2fe..c4a8eff 100644
--- a/src/debug/docs.md
+++ b/src/debug/docs.md
@@ -10,17 +10,17 @@ Path: @/src/debug
### How it fits into the larger codebase
-- Imports `captureUtmParameters` from `@/src/core/capture`, `getStoredUtmParameters`/`isStorageAvailable`/`getRawStoredValue` from `@/src/core/storage`, and `getDefaultConfig` from `@/src/config/defaults`.
+- Imports `captureUtmParameters` from `@/src/inbound/capture`, `getStoredUtmParameters`/`isStorageAvailable`/`getRawStoredValue` from `@/src/common/storage`, and `getDefaultConfig` from `@/src/config/defaults`.
- Re-exported through `@/src/index.ts` so consumers can call these functions directly.
-- Does not depend on or interact with `@/src/react` -- it operates on the core layer only.
+- Does not depend on or interact with `@/src/react` -- it operates on the inbound/common layers only.
- All functions accept an optional `ResolvedUtmConfig`; when omitted, they fall back to `getDefaultConfig()`.
### Core Implementation
- `getDiagnostics()` assembles a `DiagnosticInfo` snapshot: resolves config, captures URL params via `captureUtmParameters`, reads stored params via `getStoredUtmParameters` (passing `storageType` from config), and checks `isStorageAvailable(config.storageType)`. SSR-safe (returns empty URL and empty params when `window` is unavailable).
-- `debugUtmState()` calls `getDiagnostics()` and formats output using `console.group`/`console.table` for structured browser console display.
-- `checkUtmTracking()` calls `getDiagnostics()` and returns an array of status strings with emoji prefixes indicating state (e.g., whether params are in the URL, in storage, or if there is a mismatch suggesting the hook has not initialized yet). The storage-unavailable warning message dynamically uses `localStorage` or `sessionStorage` based on `config.storageType`.
-- `installDebugHelpers()` checks for `?debug_utm=true` in the URL query string. If present, it attaches a `window.utmDebug` object with `state()`, `check()`, `diagnostics()`, and `raw()` methods. Only activates in browser environments.
+- `debugUtmState()` calls `getDiagnostics()` and formats output using `console.group`/`console.table`. Logs `storageType` alongside key format and storage key.
+- `checkUtmTracking()` calls `getDiagnostics()` and returns an array of status strings with emoji prefixes indicating state. The storage-unavailable warning message dynamically uses `localStorage` or `sessionStorage` based on `config.storageType`.
+- `installDebugHelpers()` checks for `?debug_utm=true` in the URL query string. If present, it attaches a `window.utmDebug` object with `state()`, `check()`, `diagnostics()`, and `raw()` methods. The `raw()` helper reads from the correct storage backend based on `config.storageType`.
### Things to Know
diff --git a/src/debug/index.ts b/src/debug/index.ts
index 7ba0834..0e1a82f 100644
--- a/src/debug/index.ts
+++ b/src/debug/index.ts
@@ -7,8 +7,8 @@
*/
import type { DiagnosticInfo, ResolvedUtmConfig } from '../types'
-import { captureUtmParameters } from '../core/capture'
-import { getStoredUtmParameters, isStorageAvailable, getRawStoredValue } from '../core/storage'
+import { captureUtmParameters } from '../inbound/capture'
+import { getStoredUtmParameters, isStorageAvailable, getRawStoredValue } from '../common/storage'
import { getDefaultConfig } from '../config/defaults'
/**
diff --git a/src/docs.md b/src/docs.md
index 705b543..1eca0a5 100644
--- a/src/docs.md
+++ b/src/docs.md
@@ -5,44 +5,48 @@ Path: @/src
### Overview
- Root source directory for `@jackmisner/utm-toolkit`, a TypeScript library for capturing, storing, and appending UTM tracking parameters.
-- Contains framework-agnostic core utilities (`@/src/core`, `@/src/config`, `@/src/debug`, `@/src/types`) and an optional React integration (`@/src/react`).
+- Organized by data flow direction: `@/src/inbound` (receiving UTM-tagged traffic), `@/src/outbound` (creating UTM-tagged links), and `@/src/common` (shared utilities). Supplemented by `@/src/config`, `@/src/debug`, `@/src/types`, and an optional `@/src/react` integration.
- Exposes two package entry points: `@/src/index.ts` (main, imported as `@jackmisner/utm-toolkit`) and `@/src/react/index.ts` (imported as `@jackmisner/utm-toolkit/react`).
### How it fits into the larger codebase
-- `@/src/index.ts` is the main barrel export that re-exports everything from `core`, `config`, `debug`, and `types`. This is what consumers get when they `import from '@jackmisner/utm-toolkit'`.
+- `@/src/index.ts` is the main barrel export that re-exports everything from `inbound`, `outbound`, `common`, `config`, `debug`, and `types`. This is what consumers get when they `import from '@jackmisner/utm-toolkit'`.
- `@/src/react/index.ts` is the second entry point for React-specific exports, built as a separate bundle with React externalized.
- `@/tsup.config.ts` defines these two entry points and produces dual ESM/CJS output with TypeScript declarations.
-- `@/__tests__` mirrors this directory structure for testing.
+- `@/__tests__` mirrors this directory structure (`inbound/`, `outbound/`, `common/`, `config/`, `react/`) for testing.
- The library has zero runtime dependencies. React is an optional peer dependency used only by `@/src/react`.
### Core Implementation
-The library follows a layered architecture:
+The library follows a layered architecture organized by data flow direction:
```text
Consumer API
|
- +--> src/index.ts (barrel) -----> core/ config/ debug/ types/
+ +--> src/index.ts (barrel) -----> inbound/ outbound/ common/ config/ debug/ types/
|
- +--> src/react/index.ts --------> react/ (useUtmTracking, UtmProvider)
+ +--> src/react/index.ts --------> react/ (useUtmTracking, UtmProvider, UtmHiddenFields, UtmLinkDecorator)
|
- +--> core/ config/ types/
+ +--> inbound/ outbound/ common/ config/ types/
```
-- **types/** (`@/src/types`): Shared type definitions consumed by all other modules. Defines the dual key format system (snake_case/camelCase) and configuration interfaces.
+- **types/** (`@/src/types`): Shared type definitions consumed by all other modules. Defines the dual key format system (snake_case/camelCase), storage type, attribution mode, event callbacks, and configuration interfaces.
- **config/** (`@/src/config`): Pure configuration creation and validation. Merges partial user config with defaults to produce `ResolvedUtmConfig`.
-- **core/** (`@/src/core`): Framework-agnostic UTM operations -- capture from URLs, sanitize parameter values, filter PII, persist in sessionStorage or localStorage (with optional TTL), append to outbound URLs, convert key formats, validate URLs. All SSR-safe.
+- **common/** (`@/src/common`): Shared utilities used by both inbound and outbound pathways -- browser storage (sessionStorage/localStorage with envelope format and optional TTL), key format conversion, and URL validation.
+- **inbound/** (`@/src/inbound`): Receiving UTM-tagged traffic -- capture from URLs, sanitize values, filter PII, first-touch/last-touch attribution, and form field population.
+- **outbound/** (`@/src/outbound`): Creating UTM-tagged links -- append params to URLs, structured UTM URL builder, and automatic link decoration.
- **debug/** (`@/src/debug`): Development-time diagnostics. Assembles state snapshots and provides formatted console output and optional `window.utmDebug` helpers.
-- **react/** (`@/src/react`): React hook and context provider that orchestrate the core modules into stateful React APIs with auto-capture-on-mount behavior.
+- **react/** (`@/src/react`): React hooks, context provider, and components that orchestrate the core modules into stateful React APIs with auto-capture-on-mount behavior, form field rendering, and link decoration.
-**Key data flow**: URL with UTM params --> `capture` (with optional sanitization and PII filtering) --> `store` in sessionStorage or localStorage (with optional TTL) --> `appendToUrl` for outbound link generation.
+**Key data flow**: URL with UTM params --> `capture` (with optional sanitization and PII filtering) --> `storeWithAttribution` or `store` in sessionStorage/localStorage (with optional TTL, envelope format) --> `appendToUrl` / `buildUtmUrl` / `decorateLinks` for outbound link generation.
### Things to Know
-- **Dual key format invariant**: The library supports both `snake_case` (URL convention) and `camelCase` (TypeScript convention) throughout, but all URL-facing operations always convert to snake_case internally. This is enforced in `@/src/core/appender.ts`.
-- **SSR safety**: Every module that touches browser APIs (`window`, `sessionStorage`, `URL`, `document`) guards against their absence. The library can be imported and initialized on the server without errors.
+- **Dual key format invariant**: The library supports both `snake_case` (URL convention) and `camelCase` (TypeScript convention) throughout, but all URL-facing operations always convert to snake_case internally. This is enforced in `@/src/outbound/appender.ts`.
+- **Envelope storage format**: All stored data uses an envelope `{ params, iat, eat }` where `iat` is issued-at timestamp and `eat` is expires-at (null for no expiry). The storage module reads both envelope and flat formats for backward compatibility.
+- **SSR safety**: Every module that touches browser APIs (`window`, `sessionStorage`, `localStorage`, `URL`, `document`, `MutationObserver`) guards against their absence. The library can be imported and initialized on the server without errors.
+- **Event callbacks**: Lifecycle hooks (`onCapture`, `onStore`, `onClear`, `onAppend`, `onExpire`) are all wrapped in try-catch so a failing callback never breaks the data pipeline.
- **Two entry points**: The package.json `exports` map defines separate conditional exports for `.` and `./react`, each with ESM/CJS/types variants. React is externalized in the build so it is not bundled into the output.
-- **No runtime dependencies**: The library is self-contained. All functionality is implemented from scratch using standard Web APIs (`URL`, `URLSearchParams`, `sessionStorage`, `localStorage`).
+- **No runtime dependencies**: The library is self-contained. All functionality is implemented from scratch using standard Web APIs (`URL`, `URLSearchParams`, `sessionStorage`, `localStorage`, `MutationObserver`).
Created and maintained by Nori.
diff --git a/src/inbound/attribution.ts b/src/inbound/attribution.ts
new file mode 100644
index 0000000..9115aee
--- /dev/null
+++ b/src/inbound/attribution.ts
@@ -0,0 +1,150 @@
+/**
+ * Attribution Module
+ *
+ * Handles first-touch / last-touch UTM parameter storage.
+ * Supports three modes: 'last' (default), 'first' (write-once), and 'both'.
+ */
+
+import type { AttributionConfig, KeyFormat, StorageType, TouchType, UtmParameters } from '../types'
+import {
+ storeUtmParameters,
+ getStoredUtmParameters,
+ hasStoredUtmParameters,
+} from '../common/storage'
+
+export interface AttributionStoreOptions {
+ attribution: AttributionConfig
+ storageKey: string
+ storageType: StorageType
+ keyFormat: KeyFormat
+ ttl?: number
+ onStore?: (
+ params: UtmParameters,
+ meta: { storageType: StorageType; touch?: 'first' | 'last' },
+ ) => void
+}
+
+export interface AttributionGetOptions {
+ attribution: AttributionConfig
+ storageKey: string
+ storageType: StorageType
+ keyFormat: KeyFormat
+ touch?: TouchType
+}
+
+/**
+ * Store UTM params according to the attribution mode.
+ *
+ * - 'last': writes to main key (current behavior)
+ * - 'first': writes to first-touch key only if empty, always writes main key
+ * - 'both': writes first-touch (if empty) + last-touch (always) + main key
+ */
+export function storeWithAttribution(
+ params: UtmParameters,
+ options: AttributionStoreOptions,
+): void {
+ const { attribution, storageKey, storageType, keyFormat, ttl, onStore } = options
+
+ const firstKey = storageKey + (attribution.firstTouchSuffix ?? '_first')
+ const lastKey = storageKey + (attribution.lastTouchSuffix ?? '_last')
+ const baseStorageOpts = { storageType, keyFormat, ttl }
+
+ switch (attribution.mode) {
+ case 'last':
+ storeUtmParameters(params, { ...baseStorageOpts, storageKey })
+ if (onStore) {
+ try {
+ onStore(params, { storageType, touch: 'last' })
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+ break
+
+ case 'first': {
+ // Write first-touch only if not already set
+ const hasFirst = hasStoredUtmParameters(firstKey, storageType)
+ if (!hasFirst) {
+ storeUtmParameters(params, { ...baseStorageOpts, storageKey: firstKey })
+ if (onStore) {
+ try {
+ onStore(params, { storageType, touch: 'first' })
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+ }
+ // Also write to main key
+ storeUtmParameters(params, { ...baseStorageOpts, storageKey })
+ break
+ }
+
+ case 'both': {
+ // First-touch: write-once
+ const hasFirstTouch = hasStoredUtmParameters(firstKey, storageType)
+ if (!hasFirstTouch) {
+ storeUtmParameters(params, { ...baseStorageOpts, storageKey: firstKey })
+ if (onStore) {
+ try {
+ onStore(params, { storageType, touch: 'first' })
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+ }
+ // Last-touch: always update
+ storeUtmParameters(params, { ...baseStorageOpts, storageKey: lastKey })
+ if (onStore) {
+ try {
+ onStore(params, { storageType, touch: 'last' })
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+ // Also write to main key (same as last-touch)
+ storeUtmParameters(params, { ...baseStorageOpts, storageKey })
+ break
+ }
+
+ default: {
+ const _exhaustiveCheck: never = attribution.mode
+ throw new Error(`Unknown attribution mode: ${_exhaustiveCheck}`)
+ }
+ }
+}
+
+/**
+ * Read attributed params from the correct storage key.
+ *
+ * Default touch depends on mode:
+ * - 'first' mode → defaults to first-touch
+ * - 'last' / 'both' mode → defaults to last-touch
+ */
+export function getAttributedParams(options: AttributionGetOptions): UtmParameters | null {
+ const { attribution, storageKey, storageType, keyFormat, touch } = options
+
+ const firstKey = storageKey + (attribution.firstTouchSuffix ?? '_first')
+ const lastKey = storageKey + (attribution.lastTouchSuffix ?? '_last')
+
+ // Determine which touch type to read
+ let effectiveTouch: TouchType
+ if (touch) {
+ effectiveTouch = touch
+ } else if (attribution.mode === 'first') {
+ effectiveTouch = 'first'
+ } else {
+ effectiveTouch = 'last'
+ }
+
+ if (effectiveTouch === 'first') {
+ return getStoredUtmParameters({ storageKey: firstKey, storageType, keyFormat })
+ }
+
+ // For 'last' touch in 'both' mode, read from last-touch key
+ if (attribution.mode === 'both') {
+ return getStoredUtmParameters({ storageKey: lastKey, storageType, keyFormat })
+ }
+
+ // For 'last' mode, read from main key
+ return getStoredUtmParameters({ storageKey, storageType, keyFormat })
+}
diff --git a/src/core/capture.ts b/src/inbound/capture.ts
similarity index 92%
rename from src/core/capture.ts
rename to src/inbound/capture.ts
index 47177aa..3e6b94f 100644
--- a/src/core/capture.ts
+++ b/src/inbound/capture.ts
@@ -7,7 +7,7 @@
import type { KeyFormat, PiiFilterConfig, SanitizeConfig, UtmParameters } from '../types'
import { DEFAULT_PII_FILTER_CONFIG, DEFAULT_SANITIZE_CONFIG } from '../config/defaults'
-import { convertParams, isSnakeCaseUtmKey } from './keys'
+import { convertParams, isSnakeCaseUtmKey } from '../common/keys'
import { filterParams } from './pii-filter'
import { sanitizeParams } from './sanitizer'
@@ -26,6 +26,9 @@ export interface CaptureOptions {
/** PII filtering configuration — when enabled, detects and filters PII from values */
piiFiltering?: Partial
+
+ /** Fired after UTM params are captured from a URL */
+ onCapture?: (params: UtmParameters) => void
}
/**
@@ -68,7 +71,7 @@ function isBrowser(): boolean {
* ```
*/
export function captureUtmParameters(url?: string, options: CaptureOptions = {}): UtmParameters {
- const { keyFormat = 'snake_case', allowedParameters, sanitize, piiFiltering } = options
+ const { keyFormat = 'snake_case', allowedParameters, sanitize, piiFiltering, onCapture } = options
// Get URL, defaulting to current page URL in browser
const urlString = url ?? (isBrowser() ? window.location.href : '')
@@ -115,11 +118,18 @@ export function captureUtmParameters(url?: string, options: CaptureOptions = {})
: sanitized
// Convert to target format if needed
- if (keyFormat === 'camelCase') {
- return convertParams(captured, 'camelCase')
+ const result = keyFormat === 'camelCase' ? convertParams(captured, 'camelCase') : captured
+
+ // Fire onCapture callback if params were found
+ if (onCapture && Object.keys(result).length > 0) {
+ try {
+ onCapture(result)
+ } catch {
+ // Callbacks must not break the pipeline
+ }
}
- return captured
+ return result
} catch (error) {
// If URL parsing fails, return empty object
// This ensures the function is robust and doesn't break the app
diff --git a/src/inbound/docs.md b/src/inbound/docs.md
new file mode 100644
index 0000000..fa8a4e8
--- /dev/null
+++ b/src/inbound/docs.md
@@ -0,0 +1,55 @@
+# Noridoc: inbound
+
+Path: @/src/inbound
+
+### Overview
+
+- Utilities for the inbound data path: receiving UTM-tagged traffic, processing captured parameters, and routing them into storage or form fields.
+- Includes URL capture, value sanitization, PII filtering, first-touch/last-touch attribution, and form field population.
+- All exports are re-exported through `@/src/index.ts` to package consumers.
+
+### How it fits into the larger codebase
+
+- `@/src/react/useUtmTracking.ts` calls `captureUtmParameters` from this module during its mount-time capture flow.
+- `@/src/debug` imports `captureUtmParameters` to build diagnostic snapshots.
+- Attribution (`attribution.ts`) and form (`form.ts`) import storage functions from `@/src/common/storage`.
+- Sanitizer and PII filter are invoked during capture when their respective config options are enabled. The config objects (`SanitizeConfig`, `PiiFilterConfig`) come from `@/src/types` and are resolved in `@/src/config`.
+- Types (`AttributionConfig`, `TouchType`, `AttributionMode`, `PiiPattern`, etc.) come from `@/src/types`.
+
+### Core Implementation
+
+**Capture (`capture.ts`)** extracts UTM parameters from a URL string:
+- Parses the URL via the `URL` constructor, iterates `searchParams`, and filters to allowed parameter names.
+- Applies value sanitization (if `sanitize.enabled`) and PII filtering (if `piiFiltering.enabled`) as part of the capture pipeline.
+- `hasUtmParameters()` checks a `UtmParameters` object for any defined values.
+
+**Sanitizer (`sanitizer.ts`)** cleans UTM parameter values:
+- `sanitizeValue()` strips HTML characters, control characters, applies custom patterns, and truncates to max length.
+- `sanitizeParams()` applies sanitization to all values in a `UtmParameters` object.
+
+**PII Filter (`pii-filter.ts`)** detects and handles personally identifiable information in parameter values:
+- `detectPii()` checks a value against enabled patterns and returns the matching pattern name.
+- `filterValue()` either rejects (returns undefined) or redacts (replaces with `[REDACTED]`) based on config mode.
+- `filterParams()` applies filtering to all values in a `UtmParameters` object.
+
+**Attribution (`attribution.ts`)** handles first-touch / last-touch storage:
+- `storeWithAttribution()` writes params to different storage keys based on attribution mode:
+ - `'last'`: writes to the main key only (default, preserves existing behavior).
+ - `'first'`: writes to a first-touch suffixed key only if it does not already exist (write-once), plus always writes the main key.
+ - `'both'`: writes first-touch (write-once) + last-touch (always) + main key.
+- `getAttributedParams()` reads from the appropriate key based on mode and requested touch type. Default touch depends on mode: `'first'` mode defaults to first-touch, others default to last-touch.
+- Storage key suffixes default to `_first` and `_last` (configurable via `AttributionConfig`).
+- Fires `onStore` callback with a `touch` discriminator (`'first'` or `'last'`).
+
+**Form (`form.ts`)** populates HTML form fields with stored UTM data:
+- `populateFormFields()` supports three strategies: `'name'` (match by input name attribute), `'data-attribute'` (match by custom data attribute), and `'auto-create'` (create hidden inputs).
+- `createUtmHiddenFields()` is a convenience wrapper that always uses the auto-create strategy.
+- Both read stored params from `@/src/common/storage` and are SSR-safe (return 0 when `document` is undefined).
+
+### Things to Know
+
+- **Attribution writes the main key in all modes**: Even in `'first'` and `'both'` modes, the main storage key (without suffix) is always written with the current params. The suffixed keys provide the historical first/last values.
+- **First-touch is write-once**: `storeWithAttribution` checks `hasStoredUtmParameters` for the first-touch key before writing. Once set, first-touch params are never overwritten.
+- **Data-attribute strategy strips utm_ prefix**: In the `'data-attribute'` strategy, `populateByDataAttribute` strips the `utm_` (or `utm`) prefix and lowercases the remainder to build the short name used in attribute matching (e.g., `utm_source` -> `source`).
+
+Created and maintained by Nori.
diff --git a/src/inbound/form.ts b/src/inbound/form.ts
new file mode 100644
index 0000000..abcb1c0
--- /dev/null
+++ b/src/inbound/form.ts
@@ -0,0 +1,160 @@
+/**
+ * Form Field Population
+ *
+ * Injects stored UTM params into HTML form fields.
+ * Supports three strategies: name-based, data-attribute, and auto-create.
+ */
+
+import type { KeyFormat, StorageType, UtmParameters } from '../types'
+import { getStoredUtmParameters } from '../common/storage'
+
+export interface FormPopulateOptions {
+ /** CSS selector for forms (default: 'form') */
+ selector?: string
+ /** Field targeting strategy */
+ strategy?: 'name' | 'data-attribute' | 'auto-create'
+ /** Data attribute name for 'data-attribute' strategy (default: 'data-utm') */
+ dataAttribute?: string
+ /** Key format for field names (default: 'snake_case') */
+ keyFormat?: KeyFormat
+ /** Storage options for reading params */
+ storageKey?: string
+ storageType?: StorageType
+}
+
+/**
+ * Populate matching form fields with stored UTM data.
+ * Returns count of fields populated.
+ */
+export function populateFormFields(options: FormPopulateOptions = {}): number {
+ const {
+ selector = 'form',
+ strategy = 'auto-create',
+ dataAttribute = 'data-utm',
+ keyFormat = 'snake_case',
+ storageKey,
+ storageType,
+ } = options
+
+ if (typeof document === 'undefined') return 0
+
+ const params = getStoredUtmParameters({ storageKey, storageType, keyFormat })
+ if (!params || Object.keys(params).length === 0) return 0
+
+ const forms = document.querySelectorAll(selector)
+ if (forms.length === 0) return 0
+
+ let count = 0
+
+ switch (strategy) {
+ case 'name':
+ count = populateByName(forms, params)
+ break
+ case 'data-attribute':
+ count = populateByDataAttribute(forms, params, dataAttribute)
+ break
+ case 'auto-create':
+ count = autoCreateFields(forms, params)
+ break
+ }
+
+ return count
+}
+
+/**
+ * Create hidden input elements in matching forms.
+ * Returns count of fields populated (creates new or updates existing).
+ */
+export function createUtmHiddenFields(options: Omit = {}): number {
+ const { selector = 'form', keyFormat = 'snake_case', storageKey, storageType } = options
+
+ if (typeof document === 'undefined') return 0
+
+ const params = getStoredUtmParameters({ storageKey, storageType, keyFormat })
+ if (!params || Object.keys(params).length === 0) return 0
+
+ const forms = document.querySelectorAll(selector)
+ if (forms.length === 0) return 0
+
+ return autoCreateFields(forms, params)
+}
+
+/**
+ * Strategy: name — find inputs with name matching UTM keys and set values
+ */
+function populateByName(forms: NodeListOf, params: UtmParameters): number {
+ let count = 0
+ for (const form of forms) {
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined) continue
+ const input = form.querySelector(`input[name="${key}"]`) as HTMLInputElement | null
+ if (input) {
+ input.value = value
+ count++
+ }
+ }
+ }
+ return count
+}
+
+/**
+ * Strategy: data-attribute — find inputs with data-utm="source" etc.
+ */
+function populateByDataAttribute(
+ forms: NodeListOf,
+ params: UtmParameters,
+ dataAttribute: string,
+): number {
+ let count = 0
+ // Build a map from short name to value (e.g., "source" → "google")
+ const shortNameMap = new Map()
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined) continue
+ // Strip utm_ prefix to get short name
+ const shortName = key.replace(/^utm_/, '').replace(/^utm/, '')
+ if (shortName) {
+ // Handle snake_case: utm_source → source
+ shortNameMap.set(shortName.toLowerCase(), value)
+ }
+ }
+
+ for (const form of forms) {
+ for (const [shortName, value] of shortNameMap) {
+ const input = form.querySelector(
+ `input[${dataAttribute}="${shortName}"]`,
+ ) as HTMLInputElement | null
+ if (input) {
+ input.value = value
+ count++
+ }
+ }
+ }
+ return count
+}
+
+/**
+ * Strategy: auto-create — create hidden inputs for all UTM params
+ */
+function autoCreateFields(forms: NodeListOf, params: UtmParameters): number {
+ let count = 0
+ for (const form of forms) {
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined) continue
+ // Check if a hidden input with this name already exists
+ const existing = form.querySelector(
+ `input[type="hidden"][name="${key}"]`,
+ ) as HTMLInputElement | null
+ if (existing) {
+ existing.value = value
+ } else {
+ const input = document.createElement('input')
+ input.type = 'hidden'
+ input.name = key
+ input.value = value
+ form.appendChild(input)
+ }
+ count++
+ }
+ }
+ return count
+}
diff --git a/src/inbound/index.ts b/src/inbound/index.ts
new file mode 100644
index 0000000..2ea9292
--- /dev/null
+++ b/src/inbound/index.ts
@@ -0,0 +1,31 @@
+/**
+ * Inbound exports
+ *
+ * Utilities for receiving UTM-tagged traffic: capture, sanitize, filter PII.
+ */
+
+// Capture utilities
+export {
+ captureUtmParameters,
+ hasUtmParameters,
+ captureFromCurrentUrl,
+ captureWithReferrer,
+ type CaptureOptions,
+} from './capture'
+
+// Sanitizer utilities
+export { sanitizeValue, sanitizeParams } from './sanitizer'
+
+// PII filter utilities
+export { detectPii, filterValue, filterParams } from './pii-filter'
+
+// Form field population
+export { populateFormFields, createUtmHiddenFields, type FormPopulateOptions } from './form'
+
+// Attribution utilities
+export {
+ storeWithAttribution,
+ getAttributedParams,
+ type AttributionStoreOptions,
+ type AttributionGetOptions,
+} from './attribution'
diff --git a/src/core/pii-filter.ts b/src/inbound/pii-filter.ts
similarity index 100%
rename from src/core/pii-filter.ts
rename to src/inbound/pii-filter.ts
diff --git a/src/core/sanitizer.ts b/src/inbound/sanitizer.ts
similarity index 100%
rename from src/core/sanitizer.ts
rename to src/inbound/sanitizer.ts
diff --git a/src/index.ts b/src/index.ts
index c23e9f1..ba10954 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,7 +6,7 @@
* @packageDocumentation
*/
-// Core utilities (framework-agnostic)
+// Inbound utilities (capture, sanitize, filter)
export {
// Capture
captureUtmParameters,
@@ -15,6 +15,49 @@ export {
captureWithReferrer,
type CaptureOptions,
+ // Sanitizer
+ sanitizeValue,
+ sanitizeParams,
+
+ // PII Filter
+ detectPii,
+ filterValue,
+ filterParams,
+
+ // Form field population
+ populateFormFields,
+ createUtmHiddenFields,
+ type FormPopulateOptions,
+
+ // Attribution
+ storeWithAttribution,
+ getAttributedParams,
+ type AttributionStoreOptions,
+ type AttributionGetOptions,
+} from './inbound'
+
+// Outbound utilities (append)
+export {
+ // Appender
+ appendUtmParameters,
+ removeUtmParameters,
+ extractUtmParameters,
+
+ // Builder
+ buildUtmUrl,
+ validateUtmValues,
+ type BuildUtmUrlParams,
+ type BuildUtmUrlOptions,
+ type BuildResult,
+
+ // Link decorator
+ decorateLinks,
+ observeAndDecorateLinks,
+ type LinkDecoratorOptions,
+} from './outbound'
+
+// Common utilities (storage, keys, validator)
+export {
// Storage
storeUtmParameters,
getStoredUtmParameters,
@@ -26,11 +69,7 @@ export {
getRawStoredValue,
DEFAULT_STORAGE_KEY,
type StorageOptions,
-
- // Appender
- appendUtmParameters,
- removeUtmParameters,
- extractUtmParameters,
+ type ClearOptions,
// Keys
toSnakeCase,
@@ -50,15 +89,6 @@ export {
STANDARD_SNAKE_KEYS,
STANDARD_CAMEL_KEYS,
- // Sanitizer
- sanitizeValue,
- sanitizeParams,
-
- // PII Filter
- detectPii,
- filterValue,
- filterParams,
-
// Validator
validateUrl,
normalizeUrl,
@@ -70,7 +100,7 @@ export {
isProtocolAllowed,
getErrorMessage,
ERROR_MESSAGES,
-} from './core'
+} from './common'
// Configuration
export {
@@ -78,6 +108,7 @@ export {
DEFAULT_SANITIZE_CONFIG,
DEFAULT_PII_PATTERNS,
DEFAULT_PII_FILTER_CONFIG,
+ DEFAULT_ATTRIBUTION_CONFIG,
STANDARD_UTM_PARAMETERS,
getDefaultConfig,
createConfig,
@@ -112,4 +143,7 @@ export type {
SanitizeConfig,
PiiPattern,
PiiFilterConfig,
+ AttributionMode,
+ TouchType,
+ AttributionConfig,
} from './types'
diff --git a/src/core/appender.ts b/src/outbound/appender.ts
similarity index 95%
rename from src/core/appender.ts
rename to src/outbound/appender.ts
index 3d9303f..094aabd 100644
--- a/src/core/appender.ts
+++ b/src/outbound/appender.ts
@@ -6,7 +6,7 @@
*/
import type { AppendOptions, UtmParameters } from '../types'
-import { toSnakeCaseParams, isSnakeCaseUtmKey, isCamelCaseUtmKey } from './keys'
+import { toSnakeCaseParams, isSnakeCaseUtmKey, isCamelCaseUtmKey } from '../common/keys'
/**
* Builds a query string with proper handling of empty parameter values
@@ -100,7 +100,7 @@ export function appendUtmParameters(
utmParams: UtmParameters,
options: AppendOptions = {},
): string {
- const { toFragment = false, preserveExisting = false } = options
+ const { toFragment = false, preserveExisting = false, onAppend } = options
// Fast-path: nothing to append
if (!hasValidUtmEntries(utmParams)) {
@@ -119,13 +119,24 @@ export function appendUtmParameters(
// Parse the URL
const urlObj = new URL(url)
+ let result: string
if (toFragment) {
// === FRAGMENT-BASED PARAMETER ADDITION ===
- return appendToFragment(urlObj, snakeParams, preserveExisting)
+ result = appendToFragment(urlObj, snakeParams, preserveExisting)
} else {
// === QUERY-BASED PARAMETER ADDITION (DEFAULT) ===
- return appendToQuery(urlObj, snakeParams, preserveExisting)
+ result = appendToQuery(urlObj, snakeParams, preserveExisting)
}
+
+ if (onAppend) {
+ try {
+ onAppend(result, utmParams)
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+
+ return result
} catch (error) {
// If URL parsing fails, return the original URL unchanged
if (typeof console !== 'undefined' && console.warn) {
diff --git a/src/outbound/builder.ts b/src/outbound/builder.ts
new file mode 100644
index 0000000..464f17b
--- /dev/null
+++ b/src/outbound/builder.ts
@@ -0,0 +1,150 @@
+/**
+ * UTM Link Builder
+ *
+ * Structured API for constructing UTM-tagged URLs with validation.
+ * Uses existing validateUrl, normalizeUrl, and appendUtmParameters internally.
+ */
+
+import type { UtmParameters } from '../types'
+import { validateUrl, normalizeUrl } from '../common/validator'
+import { appendUtmParameters } from './appender'
+
+const UNSAFE_CHARS = /[&=?#]/
+
+export interface BuildUtmUrlParams {
+ url: string
+ source: string
+ medium?: string
+ campaign?: string
+ term?: string
+ content?: string
+ id?: string
+}
+
+export interface BuildUtmUrlOptions {
+ /** Validate the base URL (default: true) */
+ validate?: boolean
+ /** Normalize the URL — add https:// if missing (default: true) */
+ normalize?: boolean
+ /** Lowercase all param values (default: false) */
+ lowercaseValues?: boolean
+ /** Fire callback after building */
+ onAppend?: (url: string, params: UtmParameters) => void
+}
+
+export interface BuildResult {
+ url: string
+ valid: boolean
+ errors: string[]
+ warnings: string[]
+}
+
+/**
+ * Validate param values for unsafe characters and case consistency
+ */
+export function validateUtmValues(params: Partial>): {
+ errors: string[]
+ warnings: string[]
+} {
+ const errors: string[] = []
+ const warnings: string[] = []
+
+ const entries: [string, string | undefined][] = [
+ ['source', params.source],
+ ['medium', params.medium],
+ ['campaign', params.campaign],
+ ['term', params.term],
+ ['content', params.content],
+ ['id', params.id],
+ ]
+
+ for (const [name, value] of entries) {
+ if (value === undefined || value === '') continue
+
+ if (UNSAFE_CHARS.test(value)) {
+ errors.push(`${name} contains unsafe characters (& = ? #)`)
+ }
+
+ if (/[A-Z]/.test(value)) {
+ warnings.push(`${name} contains uppercase characters`)
+ }
+ }
+
+ return { errors, warnings }
+}
+
+/**
+ * Build a UTM-tagged URL from structured input with validation.
+ */
+export function buildUtmUrl(
+ params: BuildUtmUrlParams,
+ options: BuildUtmUrlOptions = {},
+): BuildResult {
+ const { validate = true, normalize = true, lowercaseValues = false, onAppend } = options
+
+ const errors: string[] = []
+ const warnings: string[] = []
+
+ // Validate required fields
+ if (!params.source || params.source.trim() === '') {
+ errors.push('source is required')
+ }
+
+ // Apply lowercase if configured
+ const effectiveParams: BuildUtmUrlParams = lowercaseValues
+ ? {
+ ...params,
+ source: params.source?.toLowerCase() ?? '',
+ medium: params.medium?.toLowerCase(),
+ campaign: params.campaign?.toLowerCase(),
+ term: params.term?.toLowerCase(),
+ content: params.content?.toLowerCase(),
+ id: params.id?.toLowerCase(),
+ }
+ : params
+
+ // Validate param values
+ const { errors: valueErrors, warnings: valueWarnings } = validateUtmValues(effectiveParams)
+ errors.push(...valueErrors)
+ warnings.push(...valueWarnings)
+
+ // Normalize URL if configured
+ let url = effectiveParams.url
+ if (normalize) {
+ url = normalizeUrl(url)
+ }
+
+ // Validate URL if configured
+ if (validate) {
+ const validation = validateUrl(url)
+ if (!validation.valid) {
+ errors.push('url is invalid')
+ }
+ }
+
+ // If there are errors, return early
+ if (errors.length > 0) {
+ return { url: params.url, valid: false, errors, warnings }
+ }
+
+ // Build UTM params object
+ const utmParams: UtmParameters = { utm_source: effectiveParams.source }
+ if (effectiveParams.medium) utmParams.utm_medium = effectiveParams.medium
+ if (effectiveParams.campaign) utmParams.utm_campaign = effectiveParams.campaign
+ if (effectiveParams.term) utmParams.utm_term = effectiveParams.term
+ if (effectiveParams.content) utmParams.utm_content = effectiveParams.content
+ if (effectiveParams.id) utmParams.utm_id = effectiveParams.id
+
+ // Construct final URL
+ const finalUrl = appendUtmParameters(url, utmParams)
+
+ if (onAppend) {
+ try {
+ onAppend(finalUrl, utmParams)
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+
+ return { url: finalUrl, valid: true, errors: [], warnings }
+}
diff --git a/src/outbound/decorator.ts b/src/outbound/decorator.ts
new file mode 100644
index 0000000..840c9f9
--- /dev/null
+++ b/src/outbound/decorator.ts
@@ -0,0 +1,144 @@
+/**
+ * Automatic Link Decoration
+ *
+ * Auto-appends UTM params to links on a page.
+ * Supports host filtering, skip-existing, and MutationObserver for SPAs.
+ */
+
+import type { StorageType, UtmParameters } from '../types'
+import { getStoredUtmParameters } from '../common/storage'
+import { appendUtmParameters, extractUtmParameters } from './appender'
+
+export interface LinkDecoratorOptions {
+ /** CSS selector for links (default: 'a[href]') */
+ selector?: string
+ /** Only decorate internal links — same host (default: true) */
+ internalOnly?: boolean
+ /** Additional hosts to include (when internalOnly is true) */
+ includeHosts?: string[]
+ /** Hosts to always exclude */
+ excludeHosts?: string[]
+ /** Skip links that already have UTM params (default: true) */
+ skipExisting?: boolean
+ /** Additional static params to append */
+ extraParams?: UtmParameters
+ /** Storage options for reading params */
+ storageKey?: string
+ storageType?: StorageType
+ /** Callback for each decorated link */
+ onAppend?: (url: string, params: UtmParameters) => void
+}
+
+/**
+ * Decorate all matching links on the page.
+ * Returns count of links decorated.
+ */
+export function decorateLinks(options: LinkDecoratorOptions = {}): number {
+ const {
+ selector = 'a[href]',
+ internalOnly = true,
+ includeHosts = [],
+ excludeHosts = [],
+ skipExisting = true,
+ storageKey,
+ storageType,
+ extraParams,
+ onAppend,
+ } = options
+
+ if (typeof document === 'undefined') return 0
+
+ const params = getStoredUtmParameters({ storageKey, storageType })
+ if (!params || Object.keys(params).length === 0) return 0
+
+ // Merge stored params with extra static params
+ const mergedParams: UtmParameters = extraParams ? { ...params, ...extraParams } : params
+
+ const links = document.querySelectorAll(selector)
+ if (links.length === 0) return 0
+
+ const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
+ const includeSet = new Set(includeHosts.map((h) => h.toLowerCase()))
+ const excludeSet = new Set(excludeHosts.map((h) => h.toLowerCase()))
+
+ let count = 0
+
+ for (const link of links) {
+ const href = link.href
+ if (!href) continue
+
+ let linkHost: string
+ try {
+ linkHost = new URL(href).hostname.toLowerCase()
+ } catch {
+ continue
+ }
+
+ // Exclude specified hosts
+ if (excludeSet.has(linkHost)) continue
+
+ // Internal-only filter
+ if (internalOnly) {
+ const isInternal = linkHost === currentHost.toLowerCase()
+ const isIncluded = includeSet.has(linkHost)
+ if (!isInternal && !isIncluded) continue
+ }
+
+ // Skip links that already have UTM params
+ if (skipExisting) {
+ const existing = extractUtmParameters(href)
+ if (Object.keys(existing).length > 0) continue
+ }
+
+ // Decorate the link
+ const decoratedUrl = appendUtmParameters(href, mergedParams)
+ link.href = decoratedUrl
+ count++
+
+ if (onAppend) {
+ try {
+ onAppend(decoratedUrl, mergedParams)
+ } catch {
+ // Callbacks must not break the pipeline
+ }
+ }
+ }
+
+ return count
+}
+
+/**
+ * Watch for new links via MutationObserver.
+ * Returns cleanup function to disconnect the observer.
+ */
+export function observeAndDecorateLinks(options: LinkDecoratorOptions = {}): () => void {
+ // Decorate existing links immediately
+ decorateLinks(options)
+
+ if (
+ typeof MutationObserver === 'undefined' ||
+ typeof document === 'undefined' ||
+ !document.body
+ ) {
+ return () => {}
+ }
+
+ let decorating = false
+ const observer = new MutationObserver(() => {
+ // Prevent re-entrant decoration (our own href mutations trigger MutationObserver)
+ if (decorating) return
+ decorating = true
+ try {
+ decorateLinks(options)
+ } finally {
+ decorating = false
+ }
+ })
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ })
+
+ return () => observer.disconnect()
+}
diff --git a/src/outbound/docs.md b/src/outbound/docs.md
new file mode 100644
index 0000000..58430ac
--- /dev/null
+++ b/src/outbound/docs.md
@@ -0,0 +1,47 @@
+# Noridoc: outbound
+
+Path: @/src/outbound
+
+### Overview
+
+- Utilities for the outbound data path: creating UTM-tagged links from stored or provided parameters.
+- Includes URL parameter appending, a structured UTM URL builder, and automatic link decoration.
+- All exports are re-exported through `@/src/index.ts` to package consumers.
+
+### How it fits into the larger codebase
+
+- `@/src/react/useUtmTracking.ts` calls `appendUtmParameters` from this module to build share URLs.
+- `@/src/react/UtmLinkDecorator.tsx` and `useUtmLinkDecorator` use `decorateLinks` from this module.
+- Builder (`builder.ts`) imports `validateUrl`/`normalizeUrl` from `@/src/common/validator` and `appendUtmParameters` from appender.
+- Decorator (`decorator.ts`) imports `getStoredUtmParameters` from `@/src/common/storage` and `appendUtmParameters`/`extractUtmParameters` from appender.
+- Types (`UtmParameters`, `AppendOptions`, etc.) come from `@/src/types`.
+
+### Core Implementation
+
+**Appender (`appender.ts`)** is the foundational URL manipulation module:
+- `appendUtmParameters(url, params, options?)` converts all keys to snake_case, then adds them to the URL's query string (default) or fragment. Respects `preserveExisting` option to keep or replace existing UTM params.
+- `removeUtmParameters(url)` strips all `utm_*` query parameters from a URL.
+- `extractUtmParameters(url)` reads all `utm_*` query parameters from a URL into a `UtmParameters` object.
+- All URL manipulation uses the `URL` constructor. Invalid URLs are returned unchanged.
+
+**Builder (`builder.ts`)** provides a structured API for constructing UTM-tagged URLs:
+- `buildUtmUrl(params, options?)` takes named fields (`source`, `medium`, `campaign`, `term`, `content`, `id`) plus a base `url`, validates values, optionally normalizes the URL, and returns a `BuildResult` with the final URL, validity flag, errors, and warnings.
+- Validates that `source` is required and non-empty, checks for unsafe characters (`& = ? #`) in values, and warns about uppercase characters.
+- `lowercaseValues` option lowercases all param values before building.
+- Fires optional `onAppend` callback after successful URL construction.
+- `validateUtmValues()` is exported separately for standalone value validation.
+
+**Decorator (`decorator.ts`)** auto-appends UTM params to links on a page:
+- `decorateLinks(options?)` queries the DOM for matching anchor elements, filters by host (internal-only by default), skips links with existing UTM params, and appends stored UTM params to each link's `href`.
+- Supports host allowlisting (`includeHosts`), blocklisting (`excludeHosts`), `extraParams` for additional static params, and `onAppend` callbacks.
+- `observeAndDecorateLinks(options?)` decorates existing links then installs a `MutationObserver` on `document.body` to catch dynamically added links (SPA support). Returns a cleanup function to disconnect the observer.
+- SSR-safe: both functions return 0 when `document` is undefined.
+
+### Things to Know
+
+- **Snake_case enforcement in appender**: `appendUtmParameters` always converts keys to snake_case before adding to the URL, regardless of the `keyFormat` setting. This is a core invariant of the library.
+- **Builder composes existing utilities**: `buildUtmUrl` delegates to `normalizeUrl`, `validateUrl`, and `appendUtmParameters` internally, rather than reimplementing URL manipulation.
+- **Decorator reads from storage on each call**: `decorateLinks` calls `getStoredUtmParameters` each time it runs. For the MutationObserver path, this means storage is re-read on every DOM mutation.
+- **MutationObserver granularity**: The observer watches for `childList` changes with `subtree: true` on `document.body`. Each mutation triggers a full `decorateLinks` pass. The `skipExisting` option prevents double-decoration.
+
+Created and maintained by Nori.
diff --git a/src/outbound/index.ts b/src/outbound/index.ts
new file mode 100644
index 0000000..267ad93
--- /dev/null
+++ b/src/outbound/index.ts
@@ -0,0 +1,20 @@
+/**
+ * Outbound exports
+ *
+ * Utilities for creating UTM-tagged links: append params to URLs.
+ */
+
+// Appender utilities
+export { appendUtmParameters, removeUtmParameters, extractUtmParameters } from './appender'
+
+// Builder utilities
+export {
+ buildUtmUrl,
+ validateUtmValues,
+ type BuildUtmUrlParams,
+ type BuildUtmUrlOptions,
+ type BuildResult,
+} from './builder'
+
+// Link decorator utilities
+export { decorateLinks, observeAndDecorateLinks, type LinkDecoratorOptions } from './decorator'
diff --git a/src/react/UtmHiddenFields.tsx b/src/react/UtmHiddenFields.tsx
new file mode 100644
index 0000000..cd3d367
--- /dev/null
+++ b/src/react/UtmHiddenFields.tsx
@@ -0,0 +1,36 @@
+/**
+ * UtmHiddenFields Component
+ *
+ * Renders hidden input elements for all stored UTM parameters.
+ * Useful for embedding UTM data in form submissions.
+ */
+
+import React from 'react'
+import type { KeyFormat } from '../types'
+import { getStoredUtmParameters } from '../common/storage'
+
+export interface UtmHiddenFieldsProps {
+ keyFormat?: KeyFormat
+ prefix?: string
+ storageKey?: string
+ storageType?: 'session' | 'local'
+}
+
+export function UtmHiddenFields(props: UtmHiddenFieldsProps): React.ReactElement | null {
+ const { keyFormat = 'snake_case', prefix = '', storageKey, storageType } = props
+
+ const params = getStoredUtmParameters({ storageKey, storageType, keyFormat })
+
+ if (!params || Object.keys(params).length === 0) {
+ return null
+ }
+
+ return (
+ <>
+ {Object.entries(params).map(([key, value]) => {
+ if (value === undefined) return null
+ return
+ })}
+ >
+ )
+}
diff --git a/src/react/UtmLinkDecorator.tsx b/src/react/UtmLinkDecorator.tsx
new file mode 100644
index 0000000..411f0e4
--- /dev/null
+++ b/src/react/UtmLinkDecorator.tsx
@@ -0,0 +1,56 @@
+/**
+ * UTM Link Decorator React Components
+ *
+ * Provides a component wrapper and hook for automatic link decoration within React.
+ */
+
+import React, { useRef, useEffect } from 'react'
+import type { LinkDecoratorOptions } from '../outbound/decorator'
+import { decorateLinks } from '../outbound/decorator'
+
+/**
+ * Hook that decorates links within a container ref.
+ * Re-runs decoration on mount.
+ */
+export function useUtmLinkDecorator(
+ options: LinkDecoratorOptions = {},
+): React.RefObject {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!ref.current) return
+
+ // Scope decoration to this container
+ const container = ref.current
+ const links = container.querySelectorAll(options.selector ?? 'a[href]')
+ if (links.length === 0) return
+
+ // Use decorateLinks with a scoped selector is tricky since it queries document.
+ // Instead, manually scope: temporarily add a unique attribute and use it as selector.
+ const scopeId = `__utm_decorator_${Date.now()}`
+ container.setAttribute('data-utm-scope', scopeId)
+
+ decorateLinks({
+ ...options,
+ selector: `[data-utm-scope="${scopeId}"] ${options.selector ?? 'a[href]'}`,
+ })
+
+ container.removeAttribute('data-utm-scope')
+ }, [])
+
+ return ref
+}
+
+export interface UtmLinkDecoratorProps extends LinkDecoratorOptions {
+ children: React.ReactNode
+}
+
+/**
+ * Component wrapper that decorates child links.
+ */
+export function UtmLinkDecorator(props: UtmLinkDecoratorProps): React.ReactElement {
+ const { children, ...options } = props
+ const ref = useUtmLinkDecorator(options)
+
+ return
}>{children}
+}
diff --git a/src/react/docs.md b/src/react/docs.md
index 492a2a1..2c65621 100644
--- a/src/react/docs.md
+++ b/src/react/docs.md
@@ -4,16 +4,17 @@ Path: @/src/react
### Overview
-- React integration layer providing a hook (`useUtmTracking`) and context provider (`UtmProvider`/`useUtmContext`) for UTM parameter management in React applications.
+- React integration layer providing hooks (`useUtmTracking`, `useUtmFormData`, `useUtmLinkDecorator`), a context provider (`UtmProvider`/`useUtmContext`), and components (`UtmHiddenFields`, `UtmLinkDecorator`) for UTM parameter management in React applications.
- This is the second package entry point, imported as `@jackmisner/utm-toolkit/react` and built as a separate bundle with React externalized.
-- Orchestrates the core modules (`@/src/core`) and config system (`@/src/config`) into a stateful React API.
+- Orchestrates the inbound, outbound, and common modules along with the config system into stateful React APIs.
### How it fits into the larger codebase
-- `useUtmTracking` is the primary orchestrator: it calls `createConfig()` from `@/src/config`, then uses `captureUtmParameters`, `storeUtmParameters`, `getStoredUtmParameters`, `clearStoredUtmParameters`, `appendUtmParameters`, `convertParams`, and `isSnakeCaseUtmKey` from `@/src/core`. It forwards `storageType` and `ttl` from config to all storage operations.
+- `useUtmTracking` is the primary orchestrator: it calls `createConfig()` from `@/src/config`, then uses `captureUtmParameters` from `@/src/inbound/capture`, `storeUtmParameters`/`getStoredUtmParameters`/`clearStoredUtmParameters` from `@/src/common/storage`, `appendUtmParameters` from `@/src/outbound/appender`, and `convertParams`/`isSnakeCaseUtmKey` from `@/src/common/keys`. It forwards `storageType` and `ttl` from config to all storage operations.
+- `UtmHiddenFields` and `useUtmFormData` read stored params via `@/src/common/storage` to render form fields or return form-ready data.
+- `UtmLinkDecorator` and `useUtmLinkDecorator` use `decorateLinks` from `@/src/outbound/decorator` to auto-decorate anchor elements within a React component tree.
- `UtmProvider` wraps `useUtmTracking` in a React context, enabling tree-wide access via `useUtmContext()`.
- React is externalized in the build (`tsup.config.ts` declares `external: ['react']`) and declared as an optional peer dependency. The core library works without React.
-- Types (`UseUtmTrackingReturn`, `UtmProviderProps`, etc.) come from `@/src/types`.
### Core Implementation
@@ -45,15 +46,17 @@ URL with UTM params
- Config is resolved once via `useRef(createConfig(options.config))` -- config changes after mount are not picked up.
- The `hasInitialized` ref prevents double-capture in React strict mode or re-renders.
- `appendToUrl` implements a layered merge: captured params are the base, then `shareContextParams.default` is applied, then `shareContextParams[platform]`. After merging, `excludeFromShares` filters out unwanted keys (comparing in both snake_case and camelCase).
-- `UtmProvider` memoizes the context value based on all return fields from `useUtmTracking` to prevent unnecessary re-renders of consumers.
-- `useUtmContext()` throws a descriptive error if called outside a `UtmProvider`, guiding the developer to either wrap with `` or use `useUtmTracking()` directly.
+- **Attribution in the hook**: `useUtmTracking` computes `firstTouchParams` and `lastTouchParams` based on `config.attribution.mode`. In `'last'` mode, `firstTouchParams` is null and `lastTouchParams` equals `utmParameters`. In `'first'` mode, it reads from the first-touch suffixed key. In `'both'` mode, both are read from their respective suffixed keys. These reads happen on every render (not memoized).
+- **`UtmHiddenFields`**: Renders `` elements for each stored UTM param. Reads directly from storage on each render. Supports an optional `prefix` for field names.
+- **`useUtmFormData`**: Returns a `Record` of stored UTM params, memoized on `storageKey`, `storageType`, and `keyFormat`. Designed for integration with form libraries.
+- **`useUtmLinkDecorator`**: Returns a `ref` to attach to a container element. On mount, it scopes `decorateLinks()` to that container by temporarily setting a `data-utm-scope` attribute and using it as a CSS selector prefix.
### Things to Know
- **Config is frozen at mount**: The `useRef` pattern means the resolved config never changes. If a consumer passes new config props, they will be ignored after the first render.
- **Initialization guard**: `hasInitialized.current` is a ref (not state), so the guard works correctly across strict mode double-effects without triggering re-renders.
-- **`appendToUrl` exclusion logic**: The `excludeFromShares` filter converts camelCase keys to snake_case using inline regex (not the `toSnakeCase` utility), so it duplicates some conversion logic from `@/src/core/keys.ts`.
+- **`appendToUrl` exclusion logic**: The `excludeFromShares` filter converts camelCase keys to snake_case using inline regex (not the `toSnakeCase` utility), so it duplicates some conversion logic from `@/src/common/keys.ts`.
- **Storage options forwarding**: The hook passes `storageType` and `ttl` from the resolved config to `storeUtmParameters`, `getStoredUtmParameters`, and `clearStoredUtmParameters`. The `clear` callback passes `storageType` so it clears the correct backend.
-- **SSR safety**: The `useState` initializer checks `typeof window === 'undefined'` and returns `null` for server rendering. The `capture` callback also checks before accessing `window.location`.
+- **SSR safety**: The `useState` initializer checks `typeof window === 'undefined'` and returns `null` for server rendering. The `capture` callback also checks before accessing `window.location`. Form and decorator components/hooks guard against `document` being undefined.
Created and maintained by Nori.
diff --git a/src/react/index.ts b/src/react/index.ts
index 2cabfde..cb768a5 100644
--- a/src/react/index.ts
+++ b/src/react/index.ts
@@ -6,3 +6,10 @@
export { useUtmTracking, type UseUtmTrackingOptions } from './useUtmTracking'
export { UtmProvider, useUtmContext, type UtmProviderComponentProps } from './UtmProvider'
+export { UtmHiddenFields, type UtmHiddenFieldsProps } from './UtmHiddenFields'
+export { useUtmFormData, type UseUtmFormDataOptions } from './useUtmFormData'
+export {
+ UtmLinkDecorator,
+ useUtmLinkDecorator,
+ type UtmLinkDecoratorProps,
+} from './UtmLinkDecorator'
diff --git a/src/react/useUtmFormData.ts b/src/react/useUtmFormData.ts
new file mode 100644
index 0000000..d1c9e7f
--- /dev/null
+++ b/src/react/useUtmFormData.ts
@@ -0,0 +1,31 @@
+/**
+ * useUtmFormData Hook
+ *
+ * Returns UTM data as a flat key-value record for use with form libraries.
+ */
+
+import { useMemo } from 'react'
+import { getStoredUtmParameters } from '../common/storage'
+
+export interface UseUtmFormDataOptions {
+ storageKey?: string
+ storageType?: 'session' | 'local'
+ keyFormat?: 'snake_case' | 'camelCase'
+}
+
+export function useUtmFormData(options: UseUtmFormDataOptions = {}): Record {
+ const { keyFormat = 'snake_case', storageKey, storageType } = options
+
+ return useMemo(() => {
+ const params = getStoredUtmParameters({ storageKey, storageType, keyFormat })
+ if (!params) return {}
+
+ const result: Record = {}
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ result[key] = value
+ }
+ }
+ return result
+ }, [storageKey, storageType, keyFormat])
+}
diff --git a/src/react/useUtmTracking.ts b/src/react/useUtmTracking.ts
index 684d67b..c51a753 100644
--- a/src/react/useUtmTracking.ts
+++ b/src/react/useUtmTracking.ts
@@ -5,7 +5,7 @@
* Provides a simple API for UTM tracking throughout React applications.
*/
-import { useState, useCallback, useEffect, useRef } from 'react'
+import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import type {
UtmConfig,
UtmParameters,
@@ -13,14 +13,11 @@ import type {
SharePlatform,
UseUtmTrackingReturn,
} from '../types'
-import { captureUtmParameters, hasUtmParameters as checkHasParams } from '../core/capture'
-import {
- storeUtmParameters,
- getStoredUtmParameters,
- clearStoredUtmParameters,
-} from '../core/storage'
-import { appendUtmParameters } from '../core/appender'
-import { convertParams, isSnakeCaseUtmKey } from '../core/keys'
+import { captureUtmParameters, hasUtmParameters as checkHasParams } from '../inbound/capture'
+import { getStoredUtmParameters, clearStoredUtmParameters } from '../common/storage'
+import { storeWithAttribution } from '../inbound/attribution'
+import { appendUtmParameters } from '../outbound/appender'
+import { convertParams, isSnakeCaseUtmKey } from '../common/keys'
import { createConfig } from '../config/loader'
/**
@@ -124,25 +121,30 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
allowedParameters: config.allowedParameters,
sanitize: config.sanitize,
piiFiltering: config.piiFiltering,
+ onCapture: config.onCapture,
})
// Only store if we found some parameters
if (checkHasParams(params)) {
- storeUtmParameters(params, {
+ storeWithAttribution(params, {
+ attribution: config.attribution,
storageKey: config.storageKey,
keyFormat: config.keyFormat,
storageType: config.storageType,
ttl: config.ttl,
+ onStore: config.onStore,
})
setUtmParameters(params)
} else if (checkHasParams(config.defaultParams)) {
// Use default parameters if no UTMs found and defaults are configured
const defaultParams = convertParams(config.defaultParams, config.keyFormat)
- storeUtmParameters(defaultParams, {
+ storeWithAttribution(defaultParams, {
+ attribution: config.attribution,
storageKey: config.storageKey,
keyFormat: config.keyFormat,
storageType: config.storageType,
ttl: config.ttl,
+ onStore: config.onStore,
})
setUtmParameters(defaultParams)
}
@@ -152,9 +154,24 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
* Clear stored UTM parameters
*/
const clear = useCallback(() => {
- clearStoredUtmParameters(config.storageKey, config.storageType)
+ // Clear main key
+ clearStoredUtmParameters({
+ storageKey: config.storageKey,
+ storageType: config.storageType,
+ onClear: config.onClear,
+ })
+ // Also clear attribution-suffixed keys if attribution is configured
+ const mode = config.attribution?.mode ?? 'last'
+ if (mode === 'first' || mode === 'both') {
+ const firstKey = config.storageKey + (config.attribution?.firstTouchSuffix ?? '_first')
+ clearStoredUtmParameters({ storageKey: firstKey, storageType: config.storageType })
+ }
+ if (mode === 'both') {
+ const lastKey = config.storageKey + (config.attribution?.lastTouchSuffix ?? '_last')
+ clearStoredUtmParameters({ storageKey: lastKey, storageType: config.storageType })
+ }
setUtmParameters(null)
- }, [config.storageKey, config.storageType])
+ }, [config.storageKey, config.storageType, config.onClear, config.attribution])
/**
* Append UTM parameters to a URL
@@ -212,7 +229,7 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
return url
}
- return appendUtmParameters(url, mergedParams)
+ return appendUtmParameters(url, mergedParams, { onAppend: config.onAppend })
},
[isEnabled, config, utmParameters],
)
@@ -232,6 +249,36 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
// Compute hasParams
const hasParams = checkHasParams(utmParameters)
+ // Attribution mode determines which touch params are available
+ const attributionMode = config.attribution?.mode ?? 'last'
+ const firstTouchParams = useMemo(
+ () =>
+ attributionMode === 'last'
+ ? null
+ : getStoredUtmParameters({
+ storageKey: config.storageKey + (config.attribution?.firstTouchSuffix ?? '_first'),
+ keyFormat: config.keyFormat,
+ storageType: config.storageType,
+ }),
+ // Re-read when utmParameters changes (i.e., after capture/store)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [attributionMode, config.storageKey, config.keyFormat, config.storageType, utmParameters],
+ )
+ const lastTouchParams = useMemo(
+ () =>
+ attributionMode === 'first'
+ ? null
+ : attributionMode === 'both'
+ ? getStoredUtmParameters({
+ storageKey: config.storageKey + (config.attribution?.lastTouchSuffix ?? '_last'),
+ keyFormat: config.keyFormat,
+ storageType: config.storageType,
+ })
+ : utmParameters,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [attributionMode, config.storageKey, config.keyFormat, config.storageType, utmParameters],
+ )
+
return {
utmParameters,
isEnabled,
@@ -239,5 +286,7 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
capture,
clear,
appendToUrl,
+ firstTouchParams,
+ lastTouchParams,
}
}
diff --git a/src/types/docs.md b/src/types/docs.md
index ee7517d..68511a7 100644
--- a/src/types/docs.md
+++ b/src/types/docs.md
@@ -4,33 +4,32 @@ Path: @/src/types
### Overview
-- Central type definitions for the entire library, consumed by `@/src/core`, `@/src/config`, `@/src/debug`, and `@/src/react`.
-- Defines the dual key format system (`snake_case` for URLs, `camelCase` for TypeScript) that is a core invariant of the library.
+- Central type definitions for the entire library, consumed by `@/src/inbound`, `@/src/outbound`, `@/src/common`, `@/src/config`, `@/src/debug`, and `@/src/react`.
+- Defines the dual key format system (`snake_case` for URLs, `camelCase` for TypeScript), storage backend selection, attribution modes, and event callback signatures that are core invariants of the library.
- All types are re-exported through `@/src/index.ts` to package consumers.
### How it fits into the larger codebase
- Every other module in the library imports types from here. This is the single source of truth for all shared interfaces and type aliases.
- The `UtmParameters` union type (`UtmParametersSnake | UtmParametersCamel`) is the fundamental data shape that flows through capture, storage, appending, and React state.
-- `UtmConfig` and `ResolvedUtmConfig` define the configuration contract: partial config goes in from consumers, fully-resolved config comes out from `@/src/config`.
-- `UseUtmTrackingReturn` and `UtmProviderProps` define the React integration contract used by `@/src/react`.
-- `SnakeCaseUtmKey` uses a template literal type (`utm_${string}`) that enables support for custom UTM parameters beyond the standard ones, which drives the extensibility design in `@/src/core/keys.ts` and `@/src/core/capture.ts`.
+- `UtmConfig` and `ResolvedUtmConfig` define the configuration contract: partial config goes in from consumers, fully-resolved config comes out from `@/src/config`. These interfaces carry storage configuration (`storageType`, `ttl`), attribution configuration, and event callbacks.
+- `UseUtmTrackingReturn` defines the React hook contract including `firstTouchParams` and `lastTouchParams` for attribution mode support.
+- `SnakeCaseUtmKey` uses a template literal type (`utm_${string}`) that enables support for custom UTM parameters beyond the standard ones, which drives the extensibility design in `@/src/common/keys.ts` and `@/src/inbound/capture.ts`.
### Core Implementation
-- `KeyFormat` is a string literal union (`'snake_case' | 'camelCase'`) that controls key conversion throughout the library. `StorageType` is a string literal union (`'session' | 'local'`) that controls which browser storage backend is used.
+- `KeyFormat` (`'snake_case' | 'camelCase'`) controls key conversion throughout the library. `StorageType` (`'session' | 'local'`) controls which browser storage backend is used.
+- `AttributionMode` (`'last' | 'first' | 'both'`) determines how UTM parameters are stored relative to user visits. `TouchType` (`'first' | 'last'`) selects which touch to read. `AttributionConfig` groups these with configurable key suffixes (`firstTouchSuffix`, `lastTouchSuffix`).
- `UtmParametersSnake` uses an index signature `[key: \`utm_${string}\`]` to accept arbitrary `utm_*` keys while also declaring the standard ones explicitly. `UtmParametersCamel` uses a broader `[key: string]` index signature since TypeScript template literals cannot express the camelCase pattern.
-- `ResolvedUtmConfig` mirrors `UtmConfig` but with all fields required (except `ttl`, which remains optional) -- it represents the result of merging user-provided partial config with defaults. Both `UtmConfig` and `ResolvedUtmConfig` include `storageType` (defaulting to `'session'`) and an optional `ttl` (milliseconds, only meaningful for localStorage).
-- `ShareContextParams` uses `Partial>` with a `default` key for base params and platform-specific overrides, enabling a layered merge strategy in `useUtmTracking`'s `appendToUrl` callback.
-- `AppendOptions` controls whether UTM params go into query string or fragment, and whether existing UTM params on the target URL are preserved.
-- `SanitizeConfig` defines value sanitization behavior with fields for `enabled`, `stripHtml`, `stripControlChars`, `maxLength`, and an optional `customPattern` (RegExp). It appears as `Partial` on `UtmConfig` (user input) and as a required `SanitizeConfig` on `ResolvedUtmConfig` (resolved output). This follows the same partial-in/resolved-out pattern used by the rest of the config system.
-- `PiiPattern` defines a named regex pattern with an `enabled` toggle. `PiiFilterConfig` groups these patterns with a `mode` (`'reject'` or `'redact'`), an optional `allowlistPattern` (RegExp for strict validation), and an optional synchronous `onPiiDetected` callback. Like `SanitizeConfig`, it appears as `Partial` on `UtmConfig` and as a required `PiiFilterConfig` on `ResolvedUtmConfig`.
+- `ResolvedUtmConfig` mirrors `UtmConfig` but with all fields required (except `ttl` and event callbacks, which remain optional) -- it represents the result of merging user-provided partial config with defaults. Includes `storageType` (defaulting to `'session'`), optional `ttl` (milliseconds, only meaningful for localStorage), `attribution` config, and lifecycle callbacks (`onCapture`, `onStore`, `onClear`, `onAppend`, `onExpire`).
+- Event callback signatures on `UtmConfig`/`ResolvedUtmConfig`: `onCapture(params)`, `onStore(params, meta)` where meta includes `storageType` and optional `touch`, `onClear()`, `onAppend(url, params)`, `onExpire(storageKey)`.
+- `SanitizeConfig` and `PiiFilterConfig` follow the partial-in/resolved-out pattern: `Partial<>` on `UtmConfig` (user input), required on `ResolvedUtmConfig` (resolved output).
### Things to Know
-- `UtmParameters` is a union, not an intersection. Code that receives it must handle either format, typically by detecting the format or converting via `@/src/core/keys.ts`.
+- `UtmParameters` is a union, not an intersection. Code that receives it must handle either format, typically by detecting the format or converting via `@/src/common/keys.ts`.
- `SharePlatform` is `'linkedin' | 'twitter' | 'facebook' | 'copy' | string` -- the named platforms are documentation aids, but any string is accepted.
- `DiagnosticInfo` is only used by `@/src/debug` and is meant for development-time inspection, not production data flow.
-- New features use a nested config object pattern (e.g., `sanitize: SanitizeConfig`) rather than adding flat fields to `UtmConfig`. Existing flat fields remain unchanged for backward compatibility.
+- New features use a nested config object pattern (e.g., `sanitize: SanitizeConfig`, `attribution: AttributionConfig`) rather than adding flat fields to `UtmConfig`. The exceptions are `storageType`, `ttl`, and event callbacks, which exist as flat fields.
Created and maintained by Nori.
diff --git a/src/types/index.ts b/src/types/index.ts
index 221acaf..6ba1b5c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -12,6 +12,31 @@ export type KeyFormat = 'snake_case' | 'camelCase'
*/
export type StorageType = 'session' | 'local'
+/**
+ * Attribution mode for UTM parameter storage
+ * - 'last': Only store last-touch (current behavior, default)
+ * - 'first': Only store first-touch (write-once)
+ * - 'both': Store both first-touch and last-touch
+ */
+export type AttributionMode = 'last' | 'first' | 'both'
+
+/**
+ * Touch type for reading attributed params
+ */
+export type TouchType = 'first' | 'last'
+
+/**
+ * Configuration for attribution behavior
+ */
+export interface AttributionConfig {
+ /** Attribution mode (default: 'last') */
+ mode: AttributionMode
+ /** Storage key suffix for first-touch (default: '_first') */
+ firstTouchSuffix?: string
+ /** Storage key suffix for last-touch (default: '_last') */
+ lastTouchSuffix?: string
+}
+
/**
* Standard UTM parameter keys in snake_case (URL format)
*/
@@ -92,6 +117,8 @@ export interface AppendOptions {
toFragment?: boolean
/** Keep existing UTM parameters instead of replacing them */
preserveExisting?: boolean
+ /** Fired after UTM params are appended to a URL */
+ onAppend?: (url: string, params: UtmParameters) => void
}
/**
@@ -217,6 +244,23 @@ export interface UtmConfig {
/** PII filtering configuration */
piiFiltering?: Partial
+
+ /** Attribution configuration (first-touch / last-touch) */
+ attribution?: Partial
+
+ /** Fired after UTM params are captured from a URL */
+ onCapture?: (params: UtmParameters) => void
+ /** Fired after UTM params are written to storage */
+ onStore?: (
+ params: UtmParameters,
+ meta: { storageType: StorageType; touch?: 'first' | 'last' },
+ ) => void
+ /** Fired when stored params are cleared */
+ onClear?: () => void
+ /** Fired after UTM params are appended to a URL */
+ onAppend?: (url: string, params: UtmParameters) => void
+ /** Fired when stored params expire (TTL) and are auto-cleaned */
+ onExpire?: (storageKey: string) => void
}
/**
@@ -236,6 +280,15 @@ export interface ResolvedUtmConfig {
excludeFromShares: string[]
sanitize: SanitizeConfig
piiFiltering: PiiFilterConfig
+ attribution: AttributionConfig
+ onCapture?: (params: UtmParameters) => void
+ onStore?: (
+ params: UtmParameters,
+ meta: { storageType: StorageType; touch?: 'first' | 'last' },
+ ) => void
+ onClear?: () => void
+ onAppend?: (url: string, params: UtmParameters) => void
+ onExpire?: (storageKey: string) => void
}
/**
@@ -264,6 +317,11 @@ export interface UseUtmTrackingReturn {
* @returns URL with UTM parameters appended
*/
appendToUrl: (url: string, platform?: SharePlatform) => string
+
+ /** First-touch UTM parameters (null when attribution mode is 'last') */
+ firstTouchParams: UtmParameters | null
+ /** Last-touch UTM parameters (null when attribution mode is 'first') */
+ lastTouchParams: UtmParameters | null
}
/**