diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..44ce511
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,128 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [18, 20, 22]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: TypeScript type checking
+ run: npm run lint
+
+ - name: Format check
+ run: npm run format:check
+
+ - name: Run tests with coverage
+ env:
+ CI: true
+ NODE_ENV: test
+ run: npm run coverage
+
+ - name: Build package
+ run: npm run build
+
+ - name: Upload coverage reports
+ if: matrix.node-version == 22
+ uses: codecov/codecov-action@v4
+ with:
+ directory: ./coverage
+ name: coverage
+ fail_ci_if_error: false
+
+ # Auto-format job (only runs on Node 22, separate from matrix)
+ format:
+ runs-on: ubuntu-latest
+ needs: test
+ if: failure() == false
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Format code with Prettier
+ run: npm run format
+
+ - name: Check for formatting changes
+ id: verify-changed-files
+ run: |
+ if [ -n "$(git status --porcelain)" ]; then
+ echo "changed=true" >> $GITHUB_OUTPUT
+ else
+ echo "changed=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Check if fork PR
+ id: check-fork
+ if: steps.verify-changed-files.outputs.changed == 'true'
+ run: |
+ if [ "${{ github.event_name }}" == "pull_request" ] && [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
+ echo "is_fork=true" >> $GITHUB_OUTPUT
+ else
+ echo "is_fork=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Warn about fork PRs
+ if: steps.verify-changed-files.outputs.changed == 'true' && steps.check-fork.outputs.is_fork == 'true'
+ run: |
+ echo "::warning::Formatting changes detected in fork PR. Please run 'npm run format' locally and push the changes."
+
+ - name: Commit formatting changes
+ if: steps.verify-changed-files.outputs.changed == 'true' && steps.check-fork.outputs.is_fork == 'false'
+ env:
+ BRANCH_NAME: ${{ github.head_ref }}
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ set -e
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add .
+ git commit -m "Auto-format code with Prettier
+
+ This commit was automatically generated by GitHub Actions
+ to ensure consistent code formatting across the project."
+
+ if [ "${{ github.event_name }}" == "pull_request" ]; then
+ git push origin HEAD:"$BRANCH_NAME"
+ else
+ git push origin HEAD:"$REF_NAME"
+ fi
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..cf7e690
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+dist/
+node_modules/
+coverage/
+*.md
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..af99dbc
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "arrowParens": "always"
+}
diff --git a/__tests__/config/loader.test.ts b/__tests__/config/loader.test.ts
index 6d2e86f..c4907f6 100644
--- a/__tests__/config/loader.test.ts
+++ b/__tests__/config/loader.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect } from 'vitest'
import {
createConfig,
mergeConfig,
@@ -6,111 +6,111 @@ import {
validateConfig,
DEFAULT_CONFIG,
getDefaultConfig,
-} from '../../src/config/loader';
+} from '../../src/config/loader'
describe('createConfig', () => {
it('returns default config when no options provided', () => {
- const config = createConfig();
- expect(config).toEqual(DEFAULT_CONFIG);
- });
+ const config = createConfig()
+ expect(config).toEqual(DEFAULT_CONFIG)
+ })
it('merges user config with defaults', () => {
const config = createConfig({
keyFormat: 'camelCase',
storageKey: 'custom_key',
- });
+ })
- expect(config.keyFormat).toBe('camelCase');
- expect(config.storageKey).toBe('custom_key');
- expect(config.enabled).toBe(true); // Default
- expect(config.captureOnMount).toBe(true); // Default
- });
+ expect(config.keyFormat).toBe('camelCase')
+ expect(config.storageKey).toBe('custom_key')
+ expect(config.enabled).toBe(true) // Default
+ expect(config.captureOnMount).toBe(true) // Default
+ })
it('overrides allowedParameters completely', () => {
const config = createConfig({
allowedParameters: ['utm_source'],
- });
+ })
- expect(config.allowedParameters).toEqual(['utm_source']);
- });
+ expect(config.allowedParameters).toEqual(['utm_source'])
+ })
it('merges defaultParams', () => {
const config = createConfig({
defaultParams: { utm_source: 'default' },
- });
+ })
- expect(config.defaultParams).toEqual({ utm_source: 'default' });
- });
+ expect(config.defaultParams).toEqual({ utm_source: 'default' })
+ })
it('merges shareContextParams', () => {
const config = createConfig({
shareContextParams: {
linkedin: { utm_content: 'linkedin_share' },
},
- });
+ })
- expect(config.shareContextParams.linkedin).toEqual({ utm_content: 'linkedin_share' });
- });
+ expect(config.shareContextParams.linkedin).toEqual({ utm_content: 'linkedin_share' })
+ })
it('handles excludeFromShares', () => {
const config = createConfig({
excludeFromShares: ['utm_team_id'],
- });
+ })
- expect(config.excludeFromShares).toEqual(['utm_team_id']);
- });
+ expect(config.excludeFromShares).toEqual(['utm_team_id'])
+ })
it('handles boolean options', () => {
const config = createConfig({
enabled: false,
captureOnMount: false,
appendToShares: false,
- });
+ })
- expect(config.enabled).toBe(false);
- expect(config.captureOnMount).toBe(false);
- expect(config.appendToShares).toBe(false);
- });
-});
+ expect(config.enabled).toBe(false)
+ expect(config.captureOnMount).toBe(false)
+ expect(config.appendToShares).toBe(false)
+ })
+})
describe('mergeConfig', () => {
it('merges override into base config', () => {
- const base = getDefaultConfig();
+ const base = getDefaultConfig()
const merged = mergeConfig(base, {
keyFormat: 'camelCase',
enabled: false,
- });
+ })
- expect(merged.keyFormat).toBe('camelCase');
- expect(merged.enabled).toBe(false);
- expect(merged.storageKey).toBe(base.storageKey); // Unchanged
- });
+ expect(merged.keyFormat).toBe('camelCase')
+ expect(merged.enabled).toBe(false)
+ expect(merged.storageKey).toBe(base.storageKey) // Unchanged
+ })
it('completely replaces arrays when overridden', () => {
- const base = getDefaultConfig();
+ const base = getDefaultConfig()
const merged = mergeConfig(base, {
allowedParameters: ['utm_source'],
excludeFromShares: ['utm_team_id'],
- });
+ })
- expect(merged.allowedParameters).toEqual(['utm_source']);
- expect(merged.excludeFromShares).toEqual(['utm_team_id']);
- });
+ expect(merged.allowedParameters).toEqual(['utm_source'])
+ expect(merged.excludeFromShares).toEqual(['utm_team_id'])
+ })
it('merges shareContextParams deeply', () => {
- const base = getDefaultConfig();
- base.shareContextParams = { default: { utm_medium: 'share' } };
+ const base = getDefaultConfig()
+ base.shareContextParams = { default: { utm_medium: 'share' } }
const merged = mergeConfig(base, {
shareContextParams: {
linkedin: { utm_content: 'linkedin' },
},
- });
+ })
- expect(merged.shareContextParams.default).toEqual({ utm_medium: 'share' });
- expect(merged.shareContextParams.linkedin).toEqual({ utm_content: 'linkedin' });
- });
-});
+ expect(merged.shareContextParams.default).toEqual({ utm_medium: 'share' })
+ expect(merged.shareContextParams.linkedin).toEqual({ utm_content: 'linkedin' })
+ })
+})
describe('loadConfigFromJson', () => {
it('loads valid JSON config', () => {
@@ -118,33 +118,33 @@ describe('loadConfigFromJson', () => {
enabled: true,
keyFormat: 'camelCase',
storageKey: 'my_utm',
- };
+ }
- const config = loadConfigFromJson(json);
- expect(config.enabled).toBe(true);
- expect(config.keyFormat).toBe('camelCase');
- expect(config.storageKey).toBe('my_utm');
- });
+ const config = loadConfigFromJson(json)
+ expect(config.enabled).toBe(true)
+ expect(config.keyFormat).toBe('camelCase')
+ expect(config.storageKey).toBe('my_utm')
+ })
it('returns defaults for invalid JSON', () => {
- const config = loadConfigFromJson(null);
- expect(config).toEqual(DEFAULT_CONFIG);
- });
+ const config = loadConfigFromJson(null)
+ expect(config).toEqual(DEFAULT_CONFIG)
+ })
it('returns defaults for non-object', () => {
- expect(loadConfigFromJson('string')).toEqual(DEFAULT_CONFIG);
- expect(loadConfigFromJson(123)).toEqual(DEFAULT_CONFIG);
- expect(loadConfigFromJson([])).toEqual(DEFAULT_CONFIG);
- });
+ expect(loadConfigFromJson('string')).toEqual(DEFAULT_CONFIG)
+ expect(loadConfigFromJson(123)).toEqual(DEFAULT_CONFIG)
+ expect(loadConfigFromJson([])).toEqual(DEFAULT_CONFIG)
+ })
it('handles partial JSON config', () => {
- const json = { keyFormat: 'camelCase' };
- const config = loadConfigFromJson(json);
+ const json = { keyFormat: 'camelCase' }
+ const config = loadConfigFromJson(json)
- expect(config.keyFormat).toBe('camelCase');
- expect(config.enabled).toBe(true); // Default
- });
-});
+ expect(config.keyFormat).toBe('camelCase')
+ expect(config.enabled).toBe(true) // Default
+ })
+})
describe('validateConfig', () => {
it('returns empty array for valid config', () => {
@@ -158,102 +158,102 @@ describe('validateConfig', () => {
excludeFromShares: [],
defaultParams: {},
shareContextParams: {},
- });
+ })
- expect(errors).toEqual([]);
- });
+ expect(errors).toEqual([])
+ })
it('returns empty array for empty object', () => {
- const errors = validateConfig({});
- expect(errors).toEqual([]);
- });
+ const errors = validateConfig({})
+ expect(errors).toEqual([])
+ })
it('validates enabled is boolean', () => {
- const errors = validateConfig({ enabled: 'true' });
- expect(errors).toContain('enabled must be a boolean');
- });
+ const errors = validateConfig({ enabled: 'true' })
+ expect(errors).toContain('enabled must be a boolean')
+ })
it('validates keyFormat values', () => {
- const errors = validateConfig({ keyFormat: 'invalid' });
- expect(errors).toContain('keyFormat must be "snake_case" or "camelCase"');
- });
+ const errors = validateConfig({ keyFormat: 'invalid' })
+ expect(errors).toContain('keyFormat must be "snake_case" or "camelCase"')
+ })
it('validates storageKey is string', () => {
- const errors = validateConfig({ storageKey: 123 });
- expect(errors).toContain('storageKey must be a string');
- });
+ const errors = validateConfig({ storageKey: 123 })
+ expect(errors).toContain('storageKey must be a string')
+ })
it('validates captureOnMount is boolean', () => {
- const errors = validateConfig({ captureOnMount: 'yes' });
- expect(errors).toContain('captureOnMount must be a boolean');
- });
+ const errors = validateConfig({ captureOnMount: 'yes' })
+ expect(errors).toContain('captureOnMount must be a boolean')
+ })
it('validates appendToShares is boolean', () => {
- const errors = validateConfig({ appendToShares: 1 });
- expect(errors).toContain('appendToShares must be a boolean');
- });
+ const errors = validateConfig({ appendToShares: 1 })
+ expect(errors).toContain('appendToShares must be a boolean')
+ })
it('validates allowedParameters is array of strings', () => {
expect(validateConfig({ allowedParameters: 'not array' })).toContain(
- 'allowedParameters must be an array'
- );
+ 'allowedParameters must be an array',
+ )
expect(validateConfig({ allowedParameters: [1, 2, 3] })).toContain(
- 'allowedParameters must contain only strings'
- );
- });
+ 'allowedParameters must contain only strings',
+ )
+ })
it('validates excludeFromShares is array of strings', () => {
expect(validateConfig({ excludeFromShares: {} })).toContain(
- 'excludeFromShares must be an array'
- );
+ 'excludeFromShares must be an array',
+ )
expect(validateConfig({ excludeFromShares: [true, false] })).toContain(
- 'excludeFromShares must contain only strings'
- );
- });
+ 'excludeFromShares must contain only strings',
+ )
+ })
it('validates defaultParams is object', () => {
- const errors = validateConfig({ defaultParams: 'not object' });
- expect(errors).toContain('defaultParams must be an object');
- });
+ const errors = validateConfig({ defaultParams: 'not object' })
+ expect(errors).toContain('defaultParams must be an object')
+ })
it('validates shareContextParams is object', () => {
- const errors = validateConfig({ shareContextParams: [] });
- expect(errors).toContain('shareContextParams must be an object');
- });
+ const errors = validateConfig({ shareContextParams: [] })
+ expect(errors).toContain('shareContextParams must be an object')
+ })
it('returns error for non-object config', () => {
- expect(validateConfig(null)).toEqual(['Config must be a non-null object']);
- expect(validateConfig('string')).toEqual(['Config must be a non-null object']);
- expect(validateConfig([])).toEqual(['Config must be a non-null object']);
- });
+ expect(validateConfig(null)).toEqual(['Config must be a non-null object'])
+ expect(validateConfig('string')).toEqual(['Config must be a non-null object'])
+ expect(validateConfig([])).toEqual(['Config must be a non-null object'])
+ })
it('returns multiple errors', () => {
const errors = validateConfig({
enabled: 'yes',
keyFormat: 'invalid',
storageKey: 123,
- });
+ })
- expect(errors.length).toBe(3);
- });
-});
+ expect(errors.length).toBe(3)
+ })
+})
describe('getDefaultConfig', () => {
it('returns a copy of default config', () => {
- const config1 = getDefaultConfig();
- const config2 = getDefaultConfig();
+ const config1 = getDefaultConfig()
+ const config2 = getDefaultConfig()
- expect(config1).toEqual(config2);
- expect(config1).not.toBe(config2); // Different objects
- });
+ expect(config1).toEqual(config2)
+ expect(config1).not.toBe(config2) // Different objects
+ })
it('returns config that can be modified without affecting defaults', () => {
- const config = getDefaultConfig();
- config.enabled = false;
- config.allowedParameters.push('custom');
-
- const freshConfig = getDefaultConfig();
- expect(freshConfig.enabled).toBe(true);
- expect(freshConfig.allowedParameters).not.toContain('custom');
- });
-});
+ const config = getDefaultConfig()
+ config.enabled = false
+ config.allowedParameters.push('custom')
+
+ const freshConfig = getDefaultConfig()
+ expect(freshConfig.enabled).toBe(true)
+ expect(freshConfig.allowedParameters).not.toContain('custom')
+ })
+})
diff --git a/__tests__/core/appender.test.ts b/__tests__/core/appender.test.ts
index bdb91e4..623a085 100644
--- a/__tests__/core/appender.test.ts
+++ b/__tests__/core/appender.test.ts
@@ -1,261 +1,230 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect } from 'vitest'
import {
appendUtmParameters,
removeUtmParameters,
extractUtmParameters,
-} from '../../src/core/appender';
+} from '../../src/core/appender'
describe('appendUtmParameters', () => {
describe('basic appending', () => {
it('appends UTM parameters to URL', () => {
- const result = appendUtmParameters(
- 'https://example.com',
- { utm_source: 'linkedin', utm_campaign: 'spring2025' }
- );
- expect(result).toBe('https://example.com/?utm_source=linkedin&utm_campaign=spring2025');
- });
+ const result = appendUtmParameters('https://example.com', {
+ utm_source: 'linkedin',
+ utm_campaign: 'spring2025',
+ })
+ expect(result).toBe('https://example.com/?utm_source=linkedin&utm_campaign=spring2025')
+ })
it('preserves existing non-UTM parameters', () => {
- const result = appendUtmParameters(
- 'https://example.com?ref=abc',
- { utm_source: 'test' }
- );
- expect(result).toBe('https://example.com/?ref=abc&utm_source=test');
- });
+ const result = appendUtmParameters('https://example.com?ref=abc', { utm_source: 'test' })
+ expect(result).toBe('https://example.com/?ref=abc&utm_source=test')
+ })
it('replaces existing UTM parameters', () => {
- const result = appendUtmParameters(
- 'https://example.com?utm_source=old',
- { utm_source: 'new' }
- );
- expect(result).toBe('https://example.com/?utm_source=new');
- });
+ const result = appendUtmParameters('https://example.com?utm_source=old', {
+ utm_source: 'new',
+ })
+ expect(result).toBe('https://example.com/?utm_source=new')
+ })
it('handles URL with path', () => {
- const result = appendUtmParameters(
- 'https://example.com/page/article',
- { utm_source: 'test' }
- );
- expect(result).toBe('https://example.com/page/article?utm_source=test');
- });
+ const result = appendUtmParameters('https://example.com/page/article', { utm_source: 'test' })
+ expect(result).toBe('https://example.com/page/article?utm_source=test')
+ })
it('handles URL with port', () => {
- const result = appendUtmParameters(
- 'https://example.com:8080',
- { utm_source: 'test' }
- );
- expect(result).toBe('https://example.com:8080/?utm_source=test');
- });
- });
+ const result = appendUtmParameters('https://example.com:8080', { utm_source: 'test' })
+ expect(result).toBe('https://example.com:8080/?utm_source=test')
+ })
+ })
describe('camelCase conversion', () => {
it('converts camelCase params to snake_case in URL', () => {
- const result = appendUtmParameters(
- 'https://example.com',
- { utmSource: 'linkedin', utmCampaign: 'spring2025' }
- );
- expect(result).toBe('https://example.com/?utm_source=linkedin&utm_campaign=spring2025');
- });
- });
+ const result = appendUtmParameters('https://example.com', {
+ utmSource: 'linkedin',
+ utmCampaign: 'spring2025',
+ })
+ expect(result).toBe('https://example.com/?utm_source=linkedin&utm_campaign=spring2025')
+ })
+ })
describe('fragment handling', () => {
it('preserves regular fragment', () => {
- const result = appendUtmParameters(
- 'https://example.com#section',
- { utm_source: 'test' }
- );
- expect(result).toBe('https://example.com/?utm_source=test#section');
- });
+ const result = appendUtmParameters('https://example.com#section', { utm_source: 'test' })
+ expect(result).toBe('https://example.com/?utm_source=test#section')
+ })
it('clears conflicting UTM params from fragment', () => {
- const result = appendUtmParameters(
- 'https://example.com#utm_source=old',
- { utm_source: 'new' }
- );
- expect(result).toBe('https://example.com/?utm_source=new');
- });
+ const result = appendUtmParameters('https://example.com#utm_source=old', {
+ utm_source: 'new',
+ })
+ expect(result).toBe('https://example.com/?utm_source=new')
+ })
it('preserves non-UTM params in fragment', () => {
- const result = appendUtmParameters(
- 'https://example.com#ref=abc&utm_source=old',
- { utm_source: 'new' }
- );
- expect(result).toBe('https://example.com/?utm_source=new#ref=abc');
- });
- });
+ const result = appendUtmParameters('https://example.com#ref=abc&utm_source=old', {
+ utm_source: 'new',
+ })
+ expect(result).toBe('https://example.com/?utm_source=new#ref=abc')
+ })
+ })
describe('toFragment option', () => {
it('appends to fragment when toFragment is true', () => {
const result = appendUtmParameters(
'https://example.com',
{ utm_source: 'test' },
- { toFragment: true }
- );
- expect(result).toBe('https://example.com/#utm_source=test');
- });
+ { toFragment: true },
+ )
+ expect(result).toBe('https://example.com/#utm_source=test')
+ })
it('removes conflicting params from query when using fragment', () => {
const result = appendUtmParameters(
'https://example.com?utm_source=query',
{ utm_source: 'fragment' },
- { toFragment: true }
- );
- expect(result).toBe('https://example.com/#utm_source=fragment');
- });
+ { toFragment: true },
+ )
+ expect(result).toBe('https://example.com/#utm_source=fragment')
+ })
it('preserves non-UTM query params when using fragment', () => {
const result = appendUtmParameters(
'https://example.com?ref=abc',
{ utm_source: 'test' },
- { toFragment: true }
- );
- expect(result).toBe('https://example.com/?ref=abc#utm_source=test');
- });
- });
+ { toFragment: true },
+ )
+ expect(result).toBe('https://example.com/?ref=abc#utm_source=test')
+ })
+ })
describe('preserveExisting option', () => {
it('preserves existing UTM params when preserveExisting is true', () => {
const result = appendUtmParameters(
'https://example.com?utm_source=original',
{ utm_source: 'new', utm_medium: 'email' },
- { preserveExisting: true }
- );
- expect(result).toContain('utm_source=original');
- expect(result).toContain('utm_medium=email');
- });
+ { preserveExisting: true },
+ )
+ expect(result).toContain('utm_source=original')
+ expect(result).toContain('utm_medium=email')
+ })
it('replaces by default (preserveExisting false)', () => {
- const result = appendUtmParameters(
- 'https://example.com?utm_source=original',
- { utm_source: 'new' }
- );
- expect(result).toBe('https://example.com/?utm_source=new');
- });
- });
+ const result = appendUtmParameters('https://example.com?utm_source=original', {
+ utm_source: 'new',
+ })
+ expect(result).toBe('https://example.com/?utm_source=new')
+ })
+ })
describe('empty values handling', () => {
it('handles empty parameter values', () => {
- const result = appendUtmParameters(
- 'https://example.com',
- { utm_source: 'test', utm_campaign: '' }
- );
+ const result = appendUtmParameters('https://example.com', {
+ utm_source: 'test',
+ utm_campaign: '',
+ })
// Empty values should be included without =
- expect(result).toContain('utm_source=test');
- expect(result).toContain('utm_campaign');
- expect(result).not.toContain('utm_campaign=');
- });
+ expect(result).toContain('utm_source=test')
+ expect(result).toContain('utm_campaign')
+ expect(result).not.toContain('utm_campaign=')
+ })
it('returns original URL when no valid params', () => {
- const result = appendUtmParameters(
- 'https://example.com',
- {}
- );
- expect(result).toBe('https://example.com');
- });
+ const result = appendUtmParameters('https://example.com', {})
+ expect(result).toBe('https://example.com')
+ })
it('returns original URL when all params undefined', () => {
- const result = appendUtmParameters(
- 'https://example.com',
- { utm_source: undefined }
- );
- expect(result).toBe('https://example.com');
- });
- });
+ const result = appendUtmParameters('https://example.com', { utm_source: undefined })
+ expect(result).toBe('https://example.com')
+ })
+ })
describe('URL encoding', () => {
it('properly encodes parameter values', () => {
- const result = appendUtmParameters(
- 'https://example.com',
- { utm_source: 'test value', utm_campaign: 'spring&summer' }
- );
- expect(result).toContain('utm_source=test%20value');
- expect(result).toContain('utm_campaign=spring%26summer');
- });
- });
+ const result = appendUtmParameters('https://example.com', {
+ utm_source: 'test value',
+ utm_campaign: 'spring&summer',
+ })
+ expect(result).toContain('utm_source=test%20value')
+ expect(result).toContain('utm_campaign=spring%26summer')
+ })
+ })
describe('error handling', () => {
it('returns original URL for invalid URLs', () => {
- const result = appendUtmParameters(
- 'not a valid url',
- { utm_source: 'test' }
- );
- expect(result).toBe('not a valid url');
- });
- });
-});
+ const result = appendUtmParameters('not a valid url', { utm_source: 'test' })
+ expect(result).toBe('not a valid url')
+ })
+ })
+})
describe('removeUtmParameters', () => {
it('removes all UTM parameters from URL', () => {
const result = removeUtmParameters(
- 'https://example.com?utm_source=test&utm_medium=email&ref=abc'
- );
- expect(result).toBe('https://example.com/?ref=abc');
- });
+ 'https://example.com?utm_source=test&utm_medium=email&ref=abc',
+ )
+ expect(result).toBe('https://example.com/?ref=abc')
+ })
it('removes specific UTM parameters', () => {
const result = removeUtmParameters(
'https://example.com?utm_source=test&utm_medium=email&utm_campaign=sale',
- ['utm_source', 'utm_medium']
- );
- expect(result).toContain('utm_campaign=sale');
- expect(result).not.toContain('utm_source');
- expect(result).not.toContain('utm_medium');
- });
+ ['utm_source', 'utm_medium'],
+ )
+ expect(result).toContain('utm_campaign=sale')
+ expect(result).not.toContain('utm_source')
+ expect(result).not.toContain('utm_medium')
+ })
it('removes UTM params from fragment', () => {
- const result = removeUtmParameters(
- 'https://example.com#utm_source=test&ref=abc'
- );
- expect(result).toContain('ref=abc');
- expect(result).not.toContain('utm_source');
- });
+ const result = removeUtmParameters('https://example.com#utm_source=test&ref=abc')
+ expect(result).toContain('ref=abc')
+ expect(result).not.toContain('utm_source')
+ })
it('handles URL with no UTM params', () => {
- const result = removeUtmParameters('https://example.com?ref=abc');
- expect(result).toBe('https://example.com/?ref=abc');
- });
+ const result = removeUtmParameters('https://example.com?ref=abc')
+ expect(result).toBe('https://example.com/?ref=abc')
+ })
it('returns original for invalid URL', () => {
- const result = removeUtmParameters('not a url');
- expect(result).toBe('not a url');
- });
-});
+ const result = removeUtmParameters('not a url')
+ expect(result).toBe('not a url')
+ })
+})
describe('extractUtmParameters', () => {
it('extracts UTM params from query string', () => {
const result = extractUtmParameters(
- 'https://example.com?utm_source=test&utm_campaign=sale&ref=abc'
- );
+ 'https://example.com?utm_source=test&utm_campaign=sale&ref=abc',
+ )
expect(result).toEqual({
utm_source: 'test',
utm_campaign: 'sale',
- });
- });
+ })
+ })
it('extracts UTM params from fragment', () => {
- const result = extractUtmParameters(
- 'https://example.com#utm_source=fragment&utm_medium=email'
- );
+ const result = extractUtmParameters('https://example.com#utm_source=fragment&utm_medium=email')
expect(result).toEqual({
utm_source: 'fragment',
utm_medium: 'email',
- });
- });
+ })
+ })
it('fragment params override query params', () => {
- const result = extractUtmParameters(
- 'https://example.com?utm_source=query#utm_source=fragment'
- );
- expect(result).toEqual({ utm_source: 'fragment' });
- });
+ const result = extractUtmParameters('https://example.com?utm_source=query#utm_source=fragment')
+ expect(result).toEqual({ utm_source: 'fragment' })
+ })
it('returns empty object for URL without UTM params', () => {
- const result = extractUtmParameters('https://example.com?ref=abc');
- expect(result).toEqual({});
- });
+ const result = extractUtmParameters('https://example.com?ref=abc')
+ expect(result).toEqual({})
+ })
it('returns empty object for invalid URL', () => {
- const result = extractUtmParameters('not a url');
- expect(result).toEqual({});
- });
-});
+ const result = extractUtmParameters('not a url')
+ expect(result).toEqual({})
+ })
+})
diff --git a/__tests__/core/capture.test.ts b/__tests__/core/capture.test.ts
index 3512ba7..e228814 100644
--- a/__tests__/core/capture.test.ts
+++ b/__tests__/core/capture.test.ts
@@ -1,26 +1,26 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
captureUtmParameters,
hasUtmParameters,
captureFromCurrentUrl,
-} from '../../src/core/capture';
+} from '../../src/core/capture'
describe('captureUtmParameters', () => {
describe('basic extraction', () => {
it('extracts UTM parameters from URL', () => {
const result = captureUtmParameters(
- 'https://example.com?utm_source=linkedin&utm_campaign=spring2025'
- );
+ 'https://example.com?utm_source=linkedin&utm_campaign=spring2025',
+ )
expect(result).toEqual({
utm_source: 'linkedin',
utm_campaign: 'spring2025',
- });
- });
+ })
+ })
it('extracts all standard UTM parameters', () => {
const result = captureUtmParameters(
- 'https://example.com?utm_source=google&utm_medium=cpc&utm_campaign=sale&utm_term=keyword&utm_content=banner&utm_id=123'
- );
+ 'https://example.com?utm_source=google&utm_medium=cpc&utm_campaign=sale&utm_term=keyword&utm_content=banner&utm_id=123',
+ )
expect(result).toEqual({
utm_source: 'google',
utm_medium: 'cpc',
@@ -28,195 +28,176 @@ describe('captureUtmParameters', () => {
utm_term: 'keyword',
utm_content: 'banner',
utm_id: '123',
- });
- });
+ })
+ })
it('extracts custom UTM parameters', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test&utm_team_id=team123'
- );
+ const result = captureUtmParameters('https://example.com?utm_source=test&utm_team_id=team123')
expect(result).toEqual({
utm_source: 'test',
utm_team_id: 'team123',
- });
- });
+ })
+ })
it('ignores non-UTM parameters', () => {
- const result = captureUtmParameters(
- 'https://example.com?ref=abc&utm_source=test&page=1'
- );
+ const result = captureUtmParameters('https://example.com?ref=abc&utm_source=test&page=1')
expect(result).toEqual({
utm_source: 'test',
- });
- });
+ })
+ })
it('returns empty object for URL without UTM params', () => {
- const result = captureUtmParameters('https://example.com?ref=abc&page=1');
- expect(result).toEqual({});
- });
+ const result = captureUtmParameters('https://example.com?ref=abc&page=1')
+ expect(result).toEqual({})
+ })
it('returns empty object for URL without query string', () => {
- const result = captureUtmParameters('https://example.com');
- expect(result).toEqual({});
- });
- });
+ const result = captureUtmParameters('https://example.com')
+ expect(result).toEqual({})
+ })
+ })
describe('key format conversion', () => {
it('returns snake_case by default', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test',
- { keyFormat: 'snake_case' }
- );
- expect(result).toEqual({ utm_source: 'test' });
- });
+ const result = captureUtmParameters('https://example.com?utm_source=test', {
+ keyFormat: 'snake_case',
+ })
+ expect(result).toEqual({ utm_source: 'test' })
+ })
it('converts to camelCase when specified', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test&utm_campaign=sale',
- { keyFormat: 'camelCase' }
- );
- expect(result).toEqual({ utmSource: 'test', utmCampaign: 'sale' });
- });
- });
+ const result = captureUtmParameters('https://example.com?utm_source=test&utm_campaign=sale', {
+ keyFormat: 'camelCase',
+ })
+ expect(result).toEqual({ utmSource: 'test', utmCampaign: 'sale' })
+ })
+ })
describe('allowed parameters filtering', () => {
it('filters to allowed parameters only', () => {
const result = captureUtmParameters(
'https://example.com?utm_source=test&utm_campaign=sale&utm_term=keyword',
- { allowedParameters: ['utm_source', 'utm_campaign'] }
- );
+ { allowedParameters: ['utm_source', 'utm_campaign'] },
+ )
expect(result).toEqual({
utm_source: 'test',
utm_campaign: 'sale',
- });
- });
+ })
+ })
it('returns empty when no allowed params found', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test',
- { allowedParameters: ['utm_campaign'] }
- );
- expect(result).toEqual({});
- });
+ const result = captureUtmParameters('https://example.com?utm_source=test', {
+ allowedParameters: ['utm_campaign'],
+ })
+ expect(result).toEqual({})
+ })
it('captures all when no allowedParameters specified', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test&utm_custom=value'
- );
+ const result = captureUtmParameters('https://example.com?utm_source=test&utm_custom=value')
expect(result).toEqual({
utm_source: 'test',
utm_custom: 'value',
- });
- });
- });
+ })
+ })
+ })
describe('URL encoding', () => {
it('decodes URL-encoded values', () => {
const result = captureUtmParameters(
- 'https://example.com?utm_source=my%20source&utm_campaign=test%26demo'
- );
+ 'https://example.com?utm_source=my%20source&utm_campaign=test%26demo',
+ )
expect(result).toEqual({
utm_source: 'my source',
utm_campaign: 'test&demo',
- });
- });
+ })
+ })
it('handles special characters', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test%2Bvalue'
- );
- expect(result).toEqual({ utm_source: 'test+value' });
- });
- });
+ const result = captureUtmParameters('https://example.com?utm_source=test%2Bvalue')
+ expect(result).toEqual({ utm_source: 'test+value' })
+ })
+ })
describe('edge cases', () => {
it('handles empty UTM values', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=&utm_campaign=test'
- );
+ const result = captureUtmParameters('https://example.com?utm_source=&utm_campaign=test')
expect(result).toEqual({
utm_source: '',
utm_campaign: 'test',
- });
- });
+ })
+ })
it('handles URL with fragment', () => {
- const result = captureUtmParameters(
- 'https://example.com?utm_source=test#section'
- );
- expect(result).toEqual({ utm_source: 'test' });
- });
+ const result = captureUtmParameters('https://example.com?utm_source=test#section')
+ expect(result).toEqual({ utm_source: 'test' })
+ })
it('handles URL with port', () => {
- const result = captureUtmParameters(
- 'https://example.com:8080?utm_source=test'
- );
- expect(result).toEqual({ utm_source: 'test' });
- });
+ const result = captureUtmParameters('https://example.com:8080?utm_source=test')
+ expect(result).toEqual({ utm_source: 'test' })
+ })
it('returns empty object for invalid URL', () => {
- const result = captureUtmParameters('not a valid url');
- expect(result).toEqual({});
- });
+ const result = captureUtmParameters('not a valid url')
+ expect(result).toEqual({})
+ })
it('returns empty object for empty string', () => {
- const result = captureUtmParameters('');
- expect(result).toEqual({});
- });
+ const result = captureUtmParameters('')
+ expect(result).toEqual({})
+ })
it('case sensitive - ignores UTM_ (uppercase)', () => {
- const result = captureUtmParameters(
- 'https://example.com?UTM_SOURCE=test&utm_source=correct'
- );
- expect(result).toEqual({ utm_source: 'correct' });
- });
- });
-});
+ const result = captureUtmParameters('https://example.com?UTM_SOURCE=test&utm_source=correct')
+ expect(result).toEqual({ utm_source: 'correct' })
+ })
+ })
+})
describe('hasUtmParameters', () => {
it('returns true when params have values', () => {
- expect(hasUtmParameters({ utm_source: 'test' })).toBe(true);
- expect(hasUtmParameters({ utmSource: 'test' })).toBe(true);
- });
+ expect(hasUtmParameters({ utm_source: 'test' })).toBe(true)
+ expect(hasUtmParameters({ utmSource: 'test' })).toBe(true)
+ })
it('returns false for empty object', () => {
- expect(hasUtmParameters({})).toBe(false);
- });
+ expect(hasUtmParameters({})).toBe(false)
+ })
it('returns false for null/undefined', () => {
- expect(hasUtmParameters(null)).toBe(false);
- expect(hasUtmParameters(undefined)).toBe(false);
- });
+ expect(hasUtmParameters(null)).toBe(false)
+ expect(hasUtmParameters(undefined)).toBe(false)
+ })
it('returns false when all values are empty', () => {
- expect(hasUtmParameters({ utm_source: '' })).toBe(false);
- expect(hasUtmParameters({ utm_source: '', utm_medium: '' })).toBe(false);
- });
+ expect(hasUtmParameters({ utm_source: '' })).toBe(false)
+ expect(hasUtmParameters({ utm_source: '', utm_medium: '' })).toBe(false)
+ })
it('returns false when all values are undefined', () => {
- expect(hasUtmParameters({ utm_source: undefined })).toBe(false);
- });
+ expect(hasUtmParameters({ utm_source: undefined })).toBe(false)
+ })
it('returns true when at least one value exists', () => {
- expect(hasUtmParameters({ utm_source: '', utm_medium: 'email' })).toBe(true);
- });
-});
+ expect(hasUtmParameters({ utm_source: '', utm_medium: 'email' })).toBe(true)
+ })
+})
describe('captureFromCurrentUrl', () => {
beforeEach(() => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=window_test',
search: '?utm_source=window_test',
- });
- });
+ })
+ })
it('captures from window.location.href', () => {
- const result = captureFromCurrentUrl();
- expect(result).toEqual({ utm_source: 'window_test' });
- });
+ const result = captureFromCurrentUrl()
+ expect(result).toEqual({ utm_source: 'window_test' })
+ })
it('applies options', () => {
- const result = captureFromCurrentUrl({ keyFormat: 'camelCase' });
- expect(result).toEqual({ utmSource: 'window_test' });
- });
-});
+ const result = captureFromCurrentUrl({ keyFormat: 'camelCase' })
+ expect(result).toEqual({ utmSource: 'window_test' })
+ })
+})
diff --git a/__tests__/core/keys.test.ts b/__tests__/core/keys.test.ts
index b7212e7..05b3424 100644
--- a/__tests__/core/keys.test.ts
+++ b/__tests__/core/keys.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect } from 'vitest'
import {
toSnakeCase,
toCamelCase,
@@ -12,243 +12,236 @@ import {
detectKeyFormat,
normalizeKey,
toUrlKey,
-} from '../../src/core/keys';
+} from '../../src/core/keys'
describe('toSnakeCase', () => {
it('converts standard camelCase keys to snake_case', () => {
- expect(toSnakeCase('utmSource')).toBe('utm_source');
- expect(toSnakeCase('utmMedium')).toBe('utm_medium');
- expect(toSnakeCase('utmCampaign')).toBe('utm_campaign');
- expect(toSnakeCase('utmTerm')).toBe('utm_term');
- expect(toSnakeCase('utmContent')).toBe('utm_content');
- expect(toSnakeCase('utmId')).toBe('utm_id');
- });
+ expect(toSnakeCase('utmSource')).toBe('utm_source')
+ expect(toSnakeCase('utmMedium')).toBe('utm_medium')
+ expect(toSnakeCase('utmCampaign')).toBe('utm_campaign')
+ expect(toSnakeCase('utmTerm')).toBe('utm_term')
+ expect(toSnakeCase('utmContent')).toBe('utm_content')
+ expect(toSnakeCase('utmId')).toBe('utm_id')
+ })
it('converts custom camelCase keys to snake_case', () => {
- expect(toSnakeCase('utmTeamId')).toBe('utm_team_id');
- expect(toSnakeCase('utmCustomParam')).toBe('utm_custom_param');
- });
+ expect(toSnakeCase('utmTeamId')).toBe('utm_team_id')
+ expect(toSnakeCase('utmCustomParam')).toBe('utm_custom_param')
+ })
it('returns snake_case keys unchanged', () => {
- expect(toSnakeCase('utm_source')).toBe('utm_source');
- expect(toSnakeCase('utm_team_id')).toBe('utm_team_id');
- });
+ expect(toSnakeCase('utm_source')).toBe('utm_source')
+ expect(toSnakeCase('utm_team_id')).toBe('utm_team_id')
+ })
it('handles non-utm keys', () => {
- expect(toSnakeCase('notUtm')).toBe('notUtm');
- expect(toSnakeCase('random_key')).toBe('random_key');
- });
-});
+ expect(toSnakeCase('notUtm')).toBe('notUtm')
+ expect(toSnakeCase('random_key')).toBe('random_key')
+ })
+})
describe('toCamelCase', () => {
it('converts standard snake_case keys to camelCase', () => {
- expect(toCamelCase('utm_source')).toBe('utmSource');
- expect(toCamelCase('utm_medium')).toBe('utmMedium');
- expect(toCamelCase('utm_campaign')).toBe('utmCampaign');
- expect(toCamelCase('utm_term')).toBe('utmTerm');
- expect(toCamelCase('utm_content')).toBe('utmContent');
- expect(toCamelCase('utm_id')).toBe('utmId');
- });
+ expect(toCamelCase('utm_source')).toBe('utmSource')
+ expect(toCamelCase('utm_medium')).toBe('utmMedium')
+ expect(toCamelCase('utm_campaign')).toBe('utmCampaign')
+ expect(toCamelCase('utm_term')).toBe('utmTerm')
+ expect(toCamelCase('utm_content')).toBe('utmContent')
+ expect(toCamelCase('utm_id')).toBe('utmId')
+ })
it('converts custom snake_case keys to camelCase', () => {
- expect(toCamelCase('utm_team_id')).toBe('utmTeamId');
- expect(toCamelCase('utm_custom_param')).toBe('utmCustomParam');
- });
+ expect(toCamelCase('utm_team_id')).toBe('utmTeamId')
+ expect(toCamelCase('utm_custom_param')).toBe('utmCustomParam')
+ })
it('returns camelCase keys unchanged', () => {
- expect(toCamelCase('utmSource')).toBe('utmSource');
- expect(toCamelCase('utmTeamId')).toBe('utmTeamId');
- });
+ expect(toCamelCase('utmSource')).toBe('utmSource')
+ expect(toCamelCase('utmTeamId')).toBe('utmTeamId')
+ })
it('handles non-utm keys', () => {
- expect(toCamelCase('notUtm')).toBe('notUtm');
- expect(toCamelCase('random_key')).toBe('random_key');
- });
-});
+ expect(toCamelCase('notUtm')).toBe('notUtm')
+ expect(toCamelCase('random_key')).toBe('random_key')
+ })
+})
describe('isSnakeCaseUtmKey', () => {
it('returns true for snake_case UTM keys', () => {
- expect(isSnakeCaseUtmKey('utm_source')).toBe(true);
- expect(isSnakeCaseUtmKey('utm_medium')).toBe(true);
- expect(isSnakeCaseUtmKey('utm_custom')).toBe(true);
- });
+ expect(isSnakeCaseUtmKey('utm_source')).toBe(true)
+ expect(isSnakeCaseUtmKey('utm_medium')).toBe(true)
+ expect(isSnakeCaseUtmKey('utm_custom')).toBe(true)
+ })
it('returns false for camelCase UTM keys', () => {
- expect(isSnakeCaseUtmKey('utmSource')).toBe(false);
- expect(isSnakeCaseUtmKey('utmMedium')).toBe(false);
- });
+ expect(isSnakeCaseUtmKey('utmSource')).toBe(false)
+ expect(isSnakeCaseUtmKey('utmMedium')).toBe(false)
+ })
it('returns false for non-UTM keys', () => {
- expect(isSnakeCaseUtmKey('source')).toBe(false);
- expect(isSnakeCaseUtmKey('ref')).toBe(false);
- });
-});
+ expect(isSnakeCaseUtmKey('source')).toBe(false)
+ expect(isSnakeCaseUtmKey('ref')).toBe(false)
+ })
+})
describe('isCamelCaseUtmKey', () => {
it('returns true for camelCase UTM keys', () => {
- expect(isCamelCaseUtmKey('utmSource')).toBe(true);
- expect(isCamelCaseUtmKey('utmMedium')).toBe(true);
- expect(isCamelCaseUtmKey('utmCustomParam')).toBe(true);
- });
+ expect(isCamelCaseUtmKey('utmSource')).toBe(true)
+ expect(isCamelCaseUtmKey('utmMedium')).toBe(true)
+ expect(isCamelCaseUtmKey('utmCustomParam')).toBe(true)
+ })
it('returns false for snake_case UTM keys', () => {
- expect(isCamelCaseUtmKey('utm_source')).toBe(false);
- expect(isCamelCaseUtmKey('utm_medium')).toBe(false);
- });
+ expect(isCamelCaseUtmKey('utm_source')).toBe(false)
+ expect(isCamelCaseUtmKey('utm_medium')).toBe(false)
+ })
it('returns false for non-UTM keys', () => {
- expect(isCamelCaseUtmKey('source')).toBe(false);
- expect(isCamelCaseUtmKey('utm')).toBe(false); // Just 'utm' without more
- });
-});
+ expect(isCamelCaseUtmKey('source')).toBe(false)
+ expect(isCamelCaseUtmKey('utm')).toBe(false) // Just 'utm' without more
+ })
+})
describe('isUtmKey', () => {
it('returns true for both snake_case and camelCase UTM keys', () => {
- expect(isUtmKey('utm_source')).toBe(true);
- expect(isUtmKey('utmSource')).toBe(true);
- expect(isUtmKey('utm_team_id')).toBe(true);
- expect(isUtmKey('utmTeamId')).toBe(true);
- });
+ expect(isUtmKey('utm_source')).toBe(true)
+ expect(isUtmKey('utmSource')).toBe(true)
+ expect(isUtmKey('utm_team_id')).toBe(true)
+ expect(isUtmKey('utmTeamId')).toBe(true)
+ })
it('returns false for non-UTM keys', () => {
- expect(isUtmKey('source')).toBe(false);
- expect(isUtmKey('ref')).toBe(false);
- expect(isUtmKey('utm')).toBe(false);
- });
-});
+ expect(isUtmKey('source')).toBe(false)
+ expect(isUtmKey('ref')).toBe(false)
+ expect(isUtmKey('utm')).toBe(false)
+ })
+})
describe('convertParams', () => {
it('converts params to snake_case', () => {
- const result = convertParams(
- { utmSource: 'test', utmCampaign: 'sale' },
- 'snake_case'
- );
- expect(result).toEqual({ utm_source: 'test', utm_campaign: 'sale' });
- });
+ const result = convertParams({ utmSource: 'test', utmCampaign: 'sale' }, 'snake_case')
+ expect(result).toEqual({ utm_source: 'test', utm_campaign: 'sale' })
+ })
it('converts params to camelCase', () => {
- const result = convertParams(
- { utm_source: 'test', utm_campaign: 'sale' },
- 'camelCase'
- );
- expect(result).toEqual({ utmSource: 'test', utmCampaign: 'sale' });
- });
+ const result = convertParams({ utm_source: 'test', utm_campaign: 'sale' }, 'camelCase')
+ expect(result).toEqual({ utmSource: 'test', utmCampaign: 'sale' })
+ })
it('handles empty objects', () => {
- expect(convertParams({}, 'snake_case')).toEqual({});
- expect(convertParams({}, 'camelCase')).toEqual({});
- });
+ expect(convertParams({}, 'snake_case')).toEqual({})
+ expect(convertParams({}, 'camelCase')).toEqual({})
+ })
it('handles undefined values', () => {
- const result = convertParams(
- { utm_source: 'test', utm_medium: undefined },
- 'camelCase'
- );
- expect(result).toEqual({ utmSource: 'test' });
- });
-});
+ const result = convertParams({ utm_source: 'test', utm_medium: undefined }, 'camelCase')
+ expect(result).toEqual({ utmSource: 'test' })
+ })
+})
describe('toSnakeCaseParams', () => {
it('converts all params to snake_case', () => {
const result = toSnakeCaseParams({
utmSource: 'linkedin',
utmCampaign: 'spring2025',
- });
+ })
expect(result).toEqual({
utm_source: 'linkedin',
utm_campaign: 'spring2025',
- });
- });
-});
+ })
+ })
+})
describe('toCamelCaseParams', () => {
it('converts all params to camelCase', () => {
const result = toCamelCaseParams({
utm_source: 'linkedin',
utm_campaign: 'spring2025',
- });
+ })
expect(result).toEqual({
utmSource: 'linkedin',
utmCampaign: 'spring2025',
- });
- });
-});
+ })
+ })
+})
describe('detectKeyFormat', () => {
it('detects snake_case format', () => {
- expect(detectKeyFormat({ utm_source: 'test' })).toBe('snake_case');
- expect(detectKeyFormat({ utm_source: 'test', utm_medium: 'email' })).toBe('snake_case');
- });
+ expect(detectKeyFormat({ utm_source: 'test' })).toBe('snake_case')
+ expect(detectKeyFormat({ utm_source: 'test', utm_medium: 'email' })).toBe('snake_case')
+ })
it('detects camelCase format', () => {
- expect(detectKeyFormat({ utmSource: 'test' })).toBe('camelCase');
- expect(detectKeyFormat({ utmSource: 'test', utmMedium: 'email' })).toBe('camelCase');
- });
+ expect(detectKeyFormat({ utmSource: 'test' })).toBe('camelCase')
+ expect(detectKeyFormat({ utmSource: 'test', utmMedium: 'email' })).toBe('camelCase')
+ })
it('returns snake_case as default for empty objects', () => {
- expect(detectKeyFormat({})).toBe('snake_case');
- });
-});
+ expect(detectKeyFormat({})).toBe('snake_case')
+ })
+})
describe('normalizeKey', () => {
it('normalizes to snake_case', () => {
- expect(normalizeKey('utmSource', 'snake_case')).toBe('utm_source');
- expect(normalizeKey('utm_source', 'snake_case')).toBe('utm_source');
- });
+ expect(normalizeKey('utmSource', 'snake_case')).toBe('utm_source')
+ expect(normalizeKey('utm_source', 'snake_case')).toBe('utm_source')
+ })
it('normalizes to camelCase', () => {
- expect(normalizeKey('utm_source', 'camelCase')).toBe('utmSource');
- expect(normalizeKey('utmSource', 'camelCase')).toBe('utmSource');
- });
-});
+ expect(normalizeKey('utm_source', 'camelCase')).toBe('utmSource')
+ expect(normalizeKey('utmSource', 'camelCase')).toBe('utmSource')
+ })
+})
describe('toUrlKey', () => {
it('converts camelCase to snake_case for URLs', () => {
- expect(toUrlKey('utmSource')).toBe('utm_source');
- expect(toUrlKey('utmTeamId')).toBe('utm_team_id');
- });
+ expect(toUrlKey('utmSource')).toBe('utm_source')
+ expect(toUrlKey('utmTeamId')).toBe('utm_team_id')
+ })
it('returns snake_case unchanged', () => {
- expect(toUrlKey('utm_source')).toBe('utm_source');
- expect(toUrlKey('utm_team_id')).toBe('utm_team_id');
- });
-});
+ expect(toUrlKey('utm_source')).toBe('utm_source')
+ expect(toUrlKey('utm_team_id')).toBe('utm_team_id')
+ })
+})
describe('isValidUtmParameters', () => {
it('returns true for valid snake_case params', () => {
- expect(isValidUtmParameters({ utm_source: 'test' }, 'snake_case')).toBe(true);
- expect(isValidUtmParameters({ utm_source: 'test', utm_medium: 'email' }, 'snake_case')).toBe(true);
- });
+ expect(isValidUtmParameters({ utm_source: 'test' }, 'snake_case')).toBe(true)
+ expect(isValidUtmParameters({ utm_source: 'test', utm_medium: 'email' }, 'snake_case')).toBe(
+ true,
+ )
+ })
it('returns true for valid camelCase params', () => {
- expect(isValidUtmParameters({ utmSource: 'test' }, 'camelCase')).toBe(true);
- expect(isValidUtmParameters({ utmSource: 'test', utmMedium: 'email' }, 'camelCase')).toBe(true);
- });
+ expect(isValidUtmParameters({ utmSource: 'test' }, 'camelCase')).toBe(true)
+ expect(isValidUtmParameters({ utmSource: 'test', utmMedium: 'email' }, 'camelCase')).toBe(true)
+ })
it('returns true for empty objects', () => {
- expect(isValidUtmParameters({})).toBe(true);
- });
+ expect(isValidUtmParameters({})).toBe(true)
+ })
it('returns false for non-objects', () => {
- expect(isValidUtmParameters(null)).toBe(false);
- expect(isValidUtmParameters(undefined)).toBe(false);
- expect(isValidUtmParameters('string')).toBe(false);
- expect(isValidUtmParameters(123)).toBe(false);
- expect(isValidUtmParameters([])).toBe(false);
- });
+ expect(isValidUtmParameters(null)).toBe(false)
+ expect(isValidUtmParameters(undefined)).toBe(false)
+ expect(isValidUtmParameters('string')).toBe(false)
+ expect(isValidUtmParameters(123)).toBe(false)
+ expect(isValidUtmParameters([])).toBe(false)
+ })
it('returns false for invalid values', () => {
- expect(isValidUtmParameters({ utm_source: 123 })).toBe(false);
- expect(isValidUtmParameters({ utm_source: { nested: true } })).toBe(false);
- });
+ expect(isValidUtmParameters({ utm_source: 123 })).toBe(false)
+ expect(isValidUtmParameters({ utm_source: { nested: true } })).toBe(false)
+ })
it('returns false for invalid keys in strict mode', () => {
- expect(isValidUtmParameters({ invalid_key: 'test' }, 'snake_case')).toBe(false);
- expect(isValidUtmParameters({ invalidKey: 'test' }, 'camelCase')).toBe(false);
- });
+ expect(isValidUtmParameters({ invalid_key: 'test' }, 'snake_case')).toBe(false)
+ expect(isValidUtmParameters({ invalidKey: 'test' }, 'camelCase')).toBe(false)
+ })
it('accepts either format when format not specified', () => {
- expect(isValidUtmParameters({ utm_source: 'test' })).toBe(true);
- expect(isValidUtmParameters({ utmSource: 'test' })).toBe(true);
- });
-});
+ expect(isValidUtmParameters({ utm_source: 'test' })).toBe(true)
+ expect(isValidUtmParameters({ utmSource: 'test' })).toBe(true)
+ })
+})
diff --git a/__tests__/core/storage.test.ts b/__tests__/core/storage.test.ts
index 9ab8a33..58e3236 100644
--- a/__tests__/core/storage.test.ts
+++ b/__tests__/core/storage.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
storeUtmParameters,
getStoredUtmParameters,
@@ -7,252 +7,246 @@ import {
isSessionStorageAvailable,
getRawStoredValue,
DEFAULT_STORAGE_KEY,
-} from '../../src/core/storage';
+} from '../../src/core/storage'
describe('storeUtmParameters', () => {
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('stores UTM parameters in sessionStorage', () => {
- storeUtmParameters({ utm_source: 'test', utm_campaign: 'sale' });
+ storeUtmParameters({ utm_source: 'test', utm_campaign: 'sale' })
- const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY);
- expect(stored).toBe('{"utm_source":"test","utm_campaign":"sale"}');
- });
+ const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY)
+ expect(stored).toBe('{"utm_source":"test","utm_campaign":"sale"}')
+ })
it('uses default storage key', () => {
- storeUtmParameters({ utm_source: 'test' });
- expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).not.toBeNull();
- });
+ storeUtmParameters({ utm_source: 'test' })
+ expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).not.toBeNull()
+ })
it('uses custom storage key when provided', () => {
- storeUtmParameters({ utm_source: 'test' }, { storageKey: 'custom_key' });
+ storeUtmParameters({ utm_source: 'test' }, { storageKey: 'custom_key' })
- expect(sessionStorage.getItem('custom_key')).not.toBeNull();
- expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull();
- });
+ expect(sessionStorage.getItem('custom_key')).not.toBeNull()
+ expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull()
+ })
it('skips storing empty objects', () => {
- storeUtmParameters({});
- expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull();
- });
+ storeUtmParameters({})
+ expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull()
+ })
it('converts to specified key format before storing', () => {
- storeUtmParameters(
- { utmSource: 'test', utmCampaign: 'sale' },
- { keyFormat: 'snake_case' }
- );
+ storeUtmParameters({ utmSource: 'test', utmCampaign: 'sale' }, { keyFormat: 'snake_case' })
- const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY);
- expect(stored).toBe('{"utm_source":"test","utm_campaign":"sale"}');
- });
+ const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY)
+ expect(stored).toBe('{"utm_source":"test","utm_campaign":"sale"}')
+ })
it('stores in camelCase when specified', () => {
- storeUtmParameters(
- { utm_source: 'test' },
- { keyFormat: 'camelCase' }
- );
+ storeUtmParameters({ utm_source: 'test' }, { keyFormat: 'camelCase' })
- const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY);
- expect(stored).toBe('{"utmSource":"test"}');
- });
+ const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY)
+ expect(stored).toBe('{"utmSource":"test"}')
+ })
it('fails silently on storage error', () => {
// Mock sessionStorage to throw
vi.spyOn(sessionStorage, 'setItem').mockImplementationOnce(() => {
- throw new Error('QuotaExceeded');
- });
+ throw new Error('QuotaExceeded')
+ })
// Should not throw
expect(() => {
- storeUtmParameters({ utm_source: 'test' });
- }).not.toThrow();
- });
-});
+ storeUtmParameters({ utm_source: 'test' })
+ }).not.toThrow()
+ })
+})
describe('getStoredUtmParameters', () => {
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('retrieves stored UTM parameters', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}');
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}')
- const result = getStoredUtmParameters();
- expect(result).toEqual({ utm_source: 'test' });
- });
+ const result = getStoredUtmParameters()
+ expect(result).toEqual({ utm_source: 'test' })
+ })
it('returns null when no data stored', () => {
- const result = getStoredUtmParameters();
- expect(result).toBeNull();
- });
+ const result = getStoredUtmParameters()
+ expect(result).toBeNull()
+ })
it('uses custom storage key when provided', () => {
- sessionStorage.setItem('custom_key', '{"utm_source":"custom"}');
+ sessionStorage.setItem('custom_key', '{"utm_source":"custom"}')
- const result = getStoredUtmParameters({ storageKey: 'custom_key' });
- expect(result).toEqual({ utm_source: 'custom' });
- });
+ const result = getStoredUtmParameters({ storageKey: 'custom_key' })
+ expect(result).toEqual({ utm_source: 'custom' })
+ })
it('converts to specified key format', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}');
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}')
- const result = getStoredUtmParameters({ keyFormat: 'camelCase' });
- expect(result).toEqual({ utmSource: 'test' });
- });
+ const result = getStoredUtmParameters({ keyFormat: 'camelCase' })
+ expect(result).toEqual({ utmSource: 'test' })
+ })
it('returns null for invalid JSON', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, 'not valid json');
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, 'not valid json')
- const result = getStoredUtmParameters();
- expect(result).toBeNull();
- });
+ const result = getStoredUtmParameters()
+ expect(result).toBeNull()
+ })
it('returns null for non-object values', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '"string"');
- expect(getStoredUtmParameters()).toBeNull();
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '"string"')
+ expect(getStoredUtmParameters()).toBeNull()
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '123');
- expect(getStoredUtmParameters()).toBeNull();
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '123')
+ expect(getStoredUtmParameters()).toBeNull()
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '[]');
- expect(getStoredUtmParameters()).toBeNull();
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '[]')
+ expect(getStoredUtmParameters()).toBeNull()
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, 'null');
- expect(getStoredUtmParameters()).toBeNull();
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, 'null')
+ expect(getStoredUtmParameters()).toBeNull()
+ })
it('returns null for objects with non-UTM keys', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"invalid_key":"value"}');
- expect(getStoredUtmParameters()).toBeNull();
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"invalid_key":"value"}')
+ expect(getStoredUtmParameters()).toBeNull()
+ })
it('returns null for objects with non-string values', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":123}');
- expect(getStoredUtmParameters()).toBeNull();
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":123}')
+ expect(getStoredUtmParameters()).toBeNull()
+ })
it('accepts valid objects with undefined values', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}');
- const result = getStoredUtmParameters();
- expect(result).toEqual({ utm_source: 'test' });
- });
-});
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}')
+ const result = getStoredUtmParameters()
+ expect(result).toEqual({ utm_source: 'test' })
+ })
+})
describe('clearStoredUtmParameters', () => {
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('removes stored UTM parameters', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}');
- clearStoredUtmParameters();
- expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull();
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}')
+ clearStoredUtmParameters()
+ expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull()
+ })
it('uses custom storage key when provided', () => {
- sessionStorage.setItem('custom_key', '{"utm_source":"test"}');
- clearStoredUtmParameters('custom_key');
- expect(sessionStorage.getItem('custom_key')).toBeNull();
- });
+ sessionStorage.setItem('custom_key', '{"utm_source":"test"}')
+ clearStoredUtmParameters('custom_key')
+ expect(sessionStorage.getItem('custom_key')).toBeNull()
+ })
it('does nothing if no data stored', () => {
- expect(() => clearStoredUtmParameters()).not.toThrow();
- });
+ expect(() => clearStoredUtmParameters()).not.toThrow()
+ })
it('fails silently on storage error', () => {
vi.spyOn(sessionStorage, 'removeItem').mockImplementationOnce(() => {
- throw new Error('Access denied');
- });
+ throw new Error('Access denied')
+ })
- expect(() => clearStoredUtmParameters()).not.toThrow();
- });
-});
+ expect(() => clearStoredUtmParameters()).not.toThrow()
+ })
+})
describe('hasStoredUtmParameters', () => {
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('returns true when valid UTM params stored', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}');
- expect(hasStoredUtmParameters()).toBe(true);
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}')
+ expect(hasStoredUtmParameters()).toBe(true)
+ })
it('returns false when no data stored', () => {
- expect(hasStoredUtmParameters()).toBe(false);
- });
+ expect(hasStoredUtmParameters()).toBe(false)
+ })
it('returns false for empty object', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{}');
- expect(hasStoredUtmParameters()).toBe(false);
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{}')
+ expect(hasStoredUtmParameters()).toBe(false)
+ })
it('returns false for invalid data', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, 'invalid');
- expect(hasStoredUtmParameters()).toBe(false);
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, 'invalid')
+ expect(hasStoredUtmParameters()).toBe(false)
+ })
it('uses custom storage key', () => {
- sessionStorage.setItem('custom_key', '{"utm_source":"test"}');
- expect(hasStoredUtmParameters('custom_key')).toBe(true);
- expect(hasStoredUtmParameters()).toBe(false);
- });
-});
+ sessionStorage.setItem('custom_key', '{"utm_source":"test"}')
+ expect(hasStoredUtmParameters('custom_key')).toBe(true)
+ expect(hasStoredUtmParameters()).toBe(false)
+ })
+})
describe('isSessionStorageAvailable', () => {
it('returns true when sessionStorage is available', () => {
- expect(isSessionStorageAvailable()).toBe(true);
- });
-});
+ expect(isSessionStorageAvailable()).toBe(true)
+ })
+})
describe('getRawStoredValue', () => {
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('returns raw stored value', () => {
- sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}');
- expect(getRawStoredValue()).toBe('{"utm_source":"test"}');
- });
+ sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"test"}')
+ expect(getRawStoredValue()).toBe('{"utm_source":"test"}')
+ })
it('returns null when no value stored', () => {
- expect(getRawStoredValue()).toBeNull();
- });
+ expect(getRawStoredValue()).toBeNull()
+ })
it('uses custom storage key', () => {
- sessionStorage.setItem('custom_key', 'custom_value');
- expect(getRawStoredValue('custom_key')).toBe('custom_value');
- });
-});
+ sessionStorage.setItem('custom_key', 'custom_value')
+ expect(getRawStoredValue('custom_key')).toBe('custom_value')
+ })
+})
describe('integration: store and retrieve', () => {
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('round-trips UTM parameters correctly', () => {
- const original = { utm_source: 'test', utm_medium: 'email', utm_campaign: 'sale' };
+ const original = { utm_source: 'test', utm_medium: 'email', utm_campaign: 'sale' }
- storeUtmParameters(original);
- const retrieved = getStoredUtmParameters();
+ storeUtmParameters(original)
+ const retrieved = getStoredUtmParameters()
- expect(retrieved).toEqual(original);
- });
+ expect(retrieved).toEqual(original)
+ })
it('round-trips with key format conversion', () => {
- const original = { utmSource: 'test', utmMedium: 'email' };
+ const original = { utmSource: 'test', utmMedium: 'email' }
// Store as camelCase
- storeUtmParameters(original, { keyFormat: 'camelCase' });
+ storeUtmParameters(original, { keyFormat: 'camelCase' })
// Retrieve as camelCase
- const retrievedCamel = getStoredUtmParameters({ keyFormat: 'camelCase' });
- expect(retrievedCamel).toEqual(original);
+ const retrievedCamel = getStoredUtmParameters({ keyFormat: 'camelCase' })
+ expect(retrievedCamel).toEqual(original)
// Retrieve as snake_case
- const retrievedSnake = getStoredUtmParameters({ keyFormat: 'snake_case' });
- expect(retrievedSnake).toEqual({ utm_source: 'test', utm_medium: 'email' });
- });
-});
+ const retrievedSnake = getStoredUtmParameters({ keyFormat: 'snake_case' })
+ expect(retrievedSnake).toEqual({ utm_source: 'test', utm_medium: 'email' })
+ })
+})
diff --git a/__tests__/core/validator.test.ts b/__tests__/core/validator.test.ts
index 4cffd25..0071630 100644
--- a/__tests__/core/validator.test.ts
+++ b/__tests__/core/validator.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach } from 'vitest'
import {
validateUrl,
normalizeUrl,
@@ -9,220 +9,220 @@ import {
getAllowedProtocols,
isProtocolAllowed,
getErrorMessage,
-} from '../../src/core/validator';
+} from '../../src/core/validator'
describe('validateUrl', () => {
describe('valid URLs', () => {
it('accepts HTTPS URLs with TLD', () => {
- expect(validateUrl('https://example.com')).toEqual({ valid: true });
- expect(validateUrl('https://example.co.uk')).toEqual({ valid: true });
- expect(validateUrl('https://sub.example.com')).toEqual({ valid: true });
- });
+ expect(validateUrl('https://example.com')).toEqual({ valid: true })
+ expect(validateUrl('https://example.co.uk')).toEqual({ valid: true })
+ expect(validateUrl('https://sub.example.com')).toEqual({ valid: true })
+ })
it('accepts HTTP URLs with TLD', () => {
- expect(validateUrl('http://example.com')).toEqual({ valid: true });
- expect(validateUrl('http://www.example.org')).toEqual({ valid: true });
- });
+ expect(validateUrl('http://example.com')).toEqual({ valid: true })
+ expect(validateUrl('http://www.example.org')).toEqual({ valid: true })
+ })
it('accepts URLs with paths', () => {
- expect(validateUrl('https://example.com/path/to/page')).toEqual({ valid: true });
- });
+ expect(validateUrl('https://example.com/path/to/page')).toEqual({ valid: true })
+ })
it('accepts URLs with query strings', () => {
- expect(validateUrl('https://example.com?foo=bar')).toEqual({ valid: true });
- });
+ expect(validateUrl('https://example.com?foo=bar')).toEqual({ valid: true })
+ })
it('accepts URLs with fragments', () => {
- expect(validateUrl('https://example.com#section')).toEqual({ valid: true });
- });
+ expect(validateUrl('https://example.com#section')).toEqual({ valid: true })
+ })
it('accepts URLs with ports', () => {
- expect(validateUrl('https://example.com:8080')).toEqual({ valid: true });
- });
- });
+ expect(validateUrl('https://example.com:8080')).toEqual({ valid: true })
+ })
+ })
describe('invalid URLs', () => {
it('rejects empty URLs', () => {
- const result = validateUrl('');
- expect(result.valid).toBe(false);
- expect(result.error).toBe('empty_url');
- });
+ const result = validateUrl('')
+ expect(result.valid).toBe(false)
+ expect(result.error).toBe('empty_url')
+ })
it('rejects whitespace-only URLs', () => {
- const result = validateUrl(' ');
- expect(result.valid).toBe(false);
- expect(result.error).toBe('empty_url');
- });
+ const result = validateUrl(' ')
+ expect(result.valid).toBe(false)
+ expect(result.error).toBe('empty_url')
+ })
it('rejects non-HTTP/HTTPS protocols', () => {
- const ftpResult = validateUrl('ftp://example.com');
- expect(ftpResult.valid).toBe(false);
- expect(ftpResult.error).toBe('invalid_protocol');
+ const ftpResult = validateUrl('ftp://example.com')
+ expect(ftpResult.valid).toBe(false)
+ expect(ftpResult.error).toBe('invalid_protocol')
- const fileResult = validateUrl('file:///path/to/file');
- expect(fileResult.valid).toBe(false);
- expect(fileResult.error).toBe('invalid_protocol');
- });
+ const fileResult = validateUrl('file:///path/to/file')
+ expect(fileResult.valid).toBe(false)
+ expect(fileResult.error).toBe('invalid_protocol')
+ })
it('rejects URLs without TLD', () => {
- const result = validateUrl('https://localhost');
- expect(result.valid).toBe(false);
- expect(result.error).toBe('invalid_domain');
- });
+ const result = validateUrl('https://localhost')
+ expect(result.valid).toBe(false)
+ expect(result.error).toBe('invalid_domain')
+ })
it('rejects malformed URLs', () => {
- const result = validateUrl('not a url at all');
- expect(result.valid).toBe(false);
- expect(result.error).toBe('malformed_url');
- });
+ const result = validateUrl('not a url at all')
+ expect(result.valid).toBe(false)
+ expect(result.error).toBe('malformed_url')
+ })
it('rejects URLs with only dots in domain', () => {
- const result = validateUrl('https://....');
- expect(result.valid).toBe(false);
- expect(result.error).toBe('invalid_domain');
- });
- });
+ const result = validateUrl('https://....')
+ expect(result.valid).toBe(false)
+ expect(result.error).toBe('invalid_domain')
+ })
+ })
describe('error messages', () => {
it('includes human-readable message', () => {
- const result = validateUrl('ftp://example.com');
- expect(result.message).toBeDefined();
- expect(typeof result.message).toBe('string');
- });
- });
-});
+ const result = validateUrl('ftp://example.com')
+ expect(result.message).toBeDefined()
+ expect(typeof result.message).toBe('string')
+ })
+ })
+})
describe('normalizeUrl', () => {
it('adds https:// to URLs without protocol', () => {
- expect(normalizeUrl('example.com')).toBe('https://example.com');
- expect(normalizeUrl('www.example.com')).toBe('https://www.example.com');
- });
+ expect(normalizeUrl('example.com')).toBe('https://example.com')
+ expect(normalizeUrl('www.example.com')).toBe('https://www.example.com')
+ })
it('preserves existing HTTPS protocol', () => {
- expect(normalizeUrl('https://example.com')).toBe('https://example.com');
- });
+ expect(normalizeUrl('https://example.com')).toBe('https://example.com')
+ })
it('preserves existing HTTP protocol', () => {
- expect(normalizeUrl('http://example.com')).toBe('http://example.com');
- });
+ expect(normalizeUrl('http://example.com')).toBe('http://example.com')
+ })
it('preserves other protocols', () => {
- expect(normalizeUrl('ftp://example.com')).toBe('ftp://example.com');
- });
+ expect(normalizeUrl('ftp://example.com')).toBe('ftp://example.com')
+ })
it('trims whitespace', () => {
- expect(normalizeUrl(' example.com ')).toBe('https://example.com');
- });
+ expect(normalizeUrl(' example.com ')).toBe('https://example.com')
+ })
it('handles non-string input', () => {
// @ts-expect-error testing runtime behavior
- expect(normalizeUrl(123)).toBe(123);
+ expect(normalizeUrl(123)).toBe(123)
// @ts-expect-error testing runtime behavior
- expect(normalizeUrl(null)).toBe(null);
- });
-});
+ expect(normalizeUrl(null)).toBe(null)
+ })
+})
describe('needsNormalization', () => {
it('returns true for URLs without protocol', () => {
- expect(needsNormalization('example.com')).toBe(true);
- expect(needsNormalization('www.example.com')).toBe(true);
- });
+ expect(needsNormalization('example.com')).toBe(true)
+ expect(needsNormalization('www.example.com')).toBe(true)
+ })
it('returns false for URLs with protocol', () => {
- expect(needsNormalization('https://example.com')).toBe(false);
- expect(needsNormalization('http://example.com')).toBe(false);
- expect(needsNormalization('ftp://example.com')).toBe(false);
- });
+ expect(needsNormalization('https://example.com')).toBe(false)
+ expect(needsNormalization('http://example.com')).toBe(false)
+ expect(needsNormalization('ftp://example.com')).toBe(false)
+ })
it('handles whitespace', () => {
- expect(needsNormalization(' example.com ')).toBe(true);
- expect(needsNormalization(' https://example.com ')).toBe(false);
- });
+ expect(needsNormalization(' example.com ')).toBe(true)
+ expect(needsNormalization(' https://example.com ')).toBe(false)
+ })
it('returns false for non-strings', () => {
// @ts-expect-error testing runtime behavior
- expect(needsNormalization(123)).toBe(false);
+ expect(needsNormalization(123)).toBe(false)
// @ts-expect-error testing runtime behavior
- expect(needsNormalization(null)).toBe(false);
- });
-});
+ expect(needsNormalization(null)).toBe(false)
+ })
+})
describe('validateAndNormalize', () => {
it('normalizes and validates valid URL without protocol', () => {
- const result = validateAndNormalize('example.com');
- expect(result.valid).toBe(true);
- expect(result.normalizedUrl).toBe('https://example.com');
- });
+ const result = validateAndNormalize('example.com')
+ expect(result.valid).toBe(true)
+ expect(result.normalizedUrl).toBe('https://example.com')
+ })
it('returns invalid result for invalid domain after normalization', () => {
- const result = validateAndNormalize('localhost');
- expect(result.valid).toBe(false);
- expect(result.error).toBe('invalid_domain');
- expect(result.normalizedUrl).toBe('https://localhost');
- });
+ const result = validateAndNormalize('localhost')
+ expect(result.valid).toBe(false)
+ expect(result.error).toBe('invalid_domain')
+ expect(result.normalizedUrl).toBe('https://localhost')
+ })
it('validates URLs that already have protocol', () => {
- const result = validateAndNormalize('https://example.com');
- expect(result.valid).toBe(true);
- expect(result.normalizedUrl).toBe('https://example.com');
- });
-});
+ const result = validateAndNormalize('https://example.com')
+ expect(result.valid).toBe(true)
+ expect(result.normalizedUrl).toBe('https://example.com')
+ })
+})
describe('getDefaultProtocol / setDefaultProtocol', () => {
beforeEach(() => {
- setDefaultProtocol('https://');
- });
+ setDefaultProtocol('https://')
+ })
it('returns current default protocol', () => {
- expect(getDefaultProtocol()).toBe('https://');
- });
+ expect(getDefaultProtocol()).toBe('https://')
+ })
it('allows setting custom default protocol', () => {
- setDefaultProtocol('http://');
- expect(getDefaultProtocol()).toBe('http://');
- expect(normalizeUrl('example.com')).toBe('http://example.com');
- });
+ setDefaultProtocol('http://')
+ expect(getDefaultProtocol()).toBe('http://')
+ expect(normalizeUrl('example.com')).toBe('http://example.com')
+ })
it('throws for invalid protocol format', () => {
- expect(() => setDefaultProtocol('https')).toThrow();
- expect(() => setDefaultProtocol('https:')).toThrow();
+ expect(() => setDefaultProtocol('https')).toThrow()
+ expect(() => setDefaultProtocol('https:')).toThrow()
// @ts-expect-error testing runtime behavior
- expect(() => setDefaultProtocol(123)).toThrow();
- });
-});
+ expect(() => setDefaultProtocol(123)).toThrow()
+ })
+})
describe('getAllowedProtocols', () => {
it('returns array of allowed protocols', () => {
- const protocols = getAllowedProtocols();
- expect(Array.isArray(protocols)).toBe(true);
- expect(protocols).toContain('http:');
- expect(protocols).toContain('https:');
- });
-});
+ const protocols = getAllowedProtocols()
+ expect(Array.isArray(protocols)).toBe(true)
+ expect(protocols).toContain('http:')
+ expect(protocols).toContain('https:')
+ })
+})
describe('isProtocolAllowed', () => {
it('returns true for HTTP and HTTPS', () => {
- expect(isProtocolAllowed('http:')).toBe(true);
- expect(isProtocolAllowed('https:')).toBe(true);
- });
+ expect(isProtocolAllowed('http:')).toBe(true)
+ expect(isProtocolAllowed('https:')).toBe(true)
+ })
it('returns false for other protocols', () => {
- expect(isProtocolAllowed('ftp:')).toBe(false);
- expect(isProtocolAllowed('file:')).toBe(false);
- expect(isProtocolAllowed('data:')).toBe(false);
- });
-});
+ expect(isProtocolAllowed('ftp:')).toBe(false)
+ expect(isProtocolAllowed('file:')).toBe(false)
+ expect(isProtocolAllowed('data:')).toBe(false)
+ })
+})
describe('getErrorMessage', () => {
it('returns message for known error types', () => {
- expect(getErrorMessage('invalid_protocol')).toContain('HTTP');
- expect(getErrorMessage('invalid_domain')).toContain('domain');
- expect(getErrorMessage('malformed_url')).toContain('malformed');
- expect(getErrorMessage('empty_url')).toContain('empty');
- });
+ expect(getErrorMessage('invalid_protocol')).toContain('HTTP')
+ expect(getErrorMessage('invalid_domain')).toContain('domain')
+ expect(getErrorMessage('malformed_url')).toContain('malformed')
+ expect(getErrorMessage('empty_url')).toContain('empty')
+ })
it('returns fallback for unknown error', () => {
// @ts-expect-error testing runtime behavior
- expect(getErrorMessage('unknown_error')).toBe('Unknown validation error');
- });
-});
+ expect(getErrorMessage('unknown_error')).toBe('Unknown validation error')
+ })
+})
diff --git a/__tests__/react/UtmProvider.test.tsx b/__tests__/react/UtmProvider.test.tsx
index b45c230..d4f7f52 100644
--- a/__tests__/react/UtmProvider.test.tsx
+++ b/__tests__/react/UtmProvider.test.tsx
@@ -1,113 +1,107 @@
-import React from 'react';
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { render, screen, act } from '@testing-library/react';
-import { UtmProvider, useUtmContext } from '../../src/react/UtmProvider';
+import React from 'react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import { UtmProvider, useUtmContext } from '../../src/react/UtmProvider'
// Test component that uses the context
function TestConsumer() {
- const { utmParameters, isEnabled, hasParams, appendToUrl } = useUtmContext();
+ const { utmParameters, isEnabled, hasParams, appendToUrl } = useUtmContext()
return (
{String(isEnabled)}
{String(hasParams)}
{JSON.stringify(utmParameters)}
-
- {appendToUrl('https://example.com/share')}
-
+ {appendToUrl('https://example.com/share')}
- );
+ )
}
describe('UtmProvider', () => {
beforeEach(() => {
- sessionStorage.clear();
+ sessionStorage.clear()
vi.stubGlobal('location', {
href: 'https://example.com',
search: '',
- });
- });
+ })
+ })
it('provides UTM context to children', () => {
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('enabled').textContent).toBe('true');
- });
+ expect(screen.getByTestId('enabled').textContent).toBe('true')
+ })
it('passes config to hook', () => {
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('enabled').textContent).toBe('false');
- });
+ expect(screen.getByTestId('enabled').textContent).toBe('false')
+ })
it('provides stored params to context', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"provider_test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"provider_test"}')
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('params').textContent).toBe(
- '{"utm_source":"provider_test"}'
- );
- expect(screen.getByTestId('hasParams').textContent).toBe('true');
- });
+ expect(screen.getByTestId('params').textContent).toBe('{"utm_source":"provider_test"}')
+ expect(screen.getByTestId('hasParams').textContent).toBe('true')
+ })
it('auto-captures on mount with captureOnMount true', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=auto_capture',
search: '?utm_source=auto_capture',
- });
+ })
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('params').textContent).toBe(
- '{"utm_source":"auto_capture"}'
- );
- });
+ expect(screen.getByTestId('params').textContent).toBe('{"utm_source":"auto_capture"}')
+ })
it('uses custom storage key', () => {
- sessionStorage.setItem('custom_provider_key', '{"utm_source":"custom"}');
+ sessionStorage.setItem('custom_provider_key', '{"utm_source":"custom"}')
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('params').textContent).toBe('{"utm_source":"custom"}');
- });
+ expect(screen.getByTestId('params').textContent).toBe('{"utm_source":"custom"}')
+ })
it('appendToUrl works through context', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"context_test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"context_test"}')
render(
-
- );
+ ,
+ )
expect(screen.getByTestId('appendedUrl').textContent).toBe(
- 'https://example.com/share?utm_source=context_test'
- );
- });
+ 'https://example.com/share?utm_source=context_test',
+ )
+ })
it('applies shareContextParams', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
render(
{
}}
>
-
- );
+ ,
+ )
- const url = screen.getByTestId('appendedUrl').textContent;
- expect(url).toContain('utm_source=test');
- expect(url).toContain('utm_medium=share');
- });
-});
+ const url = screen.getByTestId('appendedUrl').textContent
+ expect(url).toContain('utm_source=test')
+ expect(url).toContain('utm_medium=share')
+ })
+})
describe('useUtmContext', () => {
it('throws when used outside provider', () => {
// Suppress console.error for this test
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
- render();
- }).toThrow('useUtmContext must be used within a UtmProvider');
+ render()
+ }).toThrow('useUtmContext must be used within a UtmProvider')
- consoleSpy.mockRestore();
- });
-});
+ consoleSpy.mockRestore()
+ })
+})
describe('UtmProvider with actions', () => {
// Test component that exposes actions
function TestConsumerWithActions() {
- const { utmParameters, capture, clear, hasParams } = useUtmContext();
+ const { utmParameters, capture, clear, hasParams } = useUtmContext()
return (
@@ -156,74 +150,72 @@ describe('UtmProvider with actions', () => {
Clear
- );
+ )
}
beforeEach(() => {
- sessionStorage.clear();
- });
+ sessionStorage.clear()
+ })
it('capture action works through context', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=action_test',
search: '?utm_source=action_test',
- });
+ })
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('params').textContent).toBe('null');
+ expect(screen.getByTestId('params').textContent).toBe('null')
act(() => {
- screen.getByTestId('capture').click();
- });
+ screen.getByTestId('capture').click()
+ })
- expect(screen.getByTestId('params').textContent).toBe(
- '{"utm_source":"action_test"}'
- );
- });
+ expect(screen.getByTestId('params').textContent).toBe('{"utm_source":"action_test"}')
+ })
it('clear action works through context', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"to_clear"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"to_clear"}')
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('hasParams').textContent).toBe('true');
+ expect(screen.getByTestId('hasParams').textContent).toBe('true')
act(() => {
- screen.getByTestId('clear').click();
- });
+ screen.getByTestId('clear').click()
+ })
- expect(screen.getByTestId('hasParams').textContent).toBe('false');
- expect(screen.getByTestId('params').textContent).toBe('null');
- });
-});
+ expect(screen.getByTestId('hasParams').textContent).toBe('false')
+ expect(screen.getByTestId('params').textContent).toBe('null')
+ })
+})
describe('nested providers', () => {
function InnerConsumer() {
- const { utmParameters } = useUtmContext();
- return {JSON.stringify(utmParameters)};
+ const { utmParameters } = useUtmContext()
+ return {JSON.stringify(utmParameters)}
}
it('inner provider overrides outer', () => {
- sessionStorage.setItem('outer_key', '{"utm_source":"outer"}');
- sessionStorage.setItem('inner_key', '{"utm_source":"inner"}');
+ sessionStorage.setItem('outer_key', '{"utm_source":"outer"}')
+ sessionStorage.setItem('inner_key', '{"utm_source":"inner"}')
render(
-
- );
+ ,
+ )
- expect(screen.getByTestId('inner').textContent).toBe('{"utm_source":"inner"}');
- });
-});
+ expect(screen.getByTestId('inner').textContent).toBe('{"utm_source":"inner"}')
+ })
+})
diff --git a/__tests__/react/useUtmTracking.test.tsx b/__tests__/react/useUtmTracking.test.tsx
index 9487bf3..ab4229b 100644
--- a/__tests__/react/useUtmTracking.test.tsx
+++ b/__tests__/react/useUtmTracking.test.tsx
@@ -1,103 +1,95 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useUtmTracking } from '../../src/react/useUtmTracking';
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useUtmTracking } from '../../src/react/useUtmTracking'
describe('useUtmTracking', () => {
beforeEach(() => {
- sessionStorage.clear();
+ sessionStorage.clear()
vi.stubGlobal('location', {
href: 'https://example.com',
search: '',
- });
- });
+ })
+ })
describe('initialization', () => {
it('returns expected shape', () => {
- const { result } = renderHook(() => useUtmTracking());
+ const { result } = renderHook(() => useUtmTracking())
- expect(result.current).toHaveProperty('utmParameters');
- expect(result.current).toHaveProperty('isEnabled');
- expect(result.current).toHaveProperty('hasParams');
- expect(result.current).toHaveProperty('capture');
- expect(result.current).toHaveProperty('clear');
- expect(result.current).toHaveProperty('appendToUrl');
- });
+ expect(result.current).toHaveProperty('utmParameters')
+ expect(result.current).toHaveProperty('isEnabled')
+ expect(result.current).toHaveProperty('hasParams')
+ expect(result.current).toHaveProperty('capture')
+ expect(result.current).toHaveProperty('clear')
+ expect(result.current).toHaveProperty('appendToUrl')
+ })
it('is enabled by default', () => {
- const { result } = renderHook(() => useUtmTracking());
- expect(result.current.isEnabled).toBe(true);
- });
+ const { result } = renderHook(() => useUtmTracking())
+ expect(result.current.isEnabled).toBe(true)
+ })
it('can be disabled via config', () => {
- const { result } = renderHook(() =>
- useUtmTracking({ config: { enabled: false } })
- );
- expect(result.current.isEnabled).toBe(false);
- });
+ const { result } = renderHook(() => useUtmTracking({ config: { enabled: false } }))
+ expect(result.current.isEnabled).toBe(false)
+ })
it('initializes with null params when storage is empty', () => {
- const { result } = renderHook(() => useUtmTracking());
- expect(result.current.utmParameters).toBeNull();
- expect(result.current.hasParams).toBe(false);
- });
+ const { result } = renderHook(() => useUtmTracking())
+ expect(result.current.utmParameters).toBeNull()
+ expect(result.current.hasParams).toBe(false)
+ })
it('initializes with stored params if available', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"stored"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"stored"}')
- const { result } = renderHook(() => useUtmTracking());
- expect(result.current.utmParameters).toEqual({ utm_source: 'stored' });
- expect(result.current.hasParams).toBe(true);
- });
+ const { result } = renderHook(() => useUtmTracking())
+ expect(result.current.utmParameters).toEqual({ utm_source: 'stored' })
+ expect(result.current.hasParams).toBe(true)
+ })
it('uses custom storage key', () => {
- sessionStorage.setItem('custom_key', '{"utm_source":"custom"}');
+ sessionStorage.setItem('custom_key', '{"utm_source":"custom"}')
- const { result } = renderHook(() =>
- useUtmTracking({ config: { storageKey: 'custom_key' } })
- );
- expect(result.current.utmParameters).toEqual({ utm_source: 'custom' });
- });
- });
+ const { result } = renderHook(() => useUtmTracking({ config: { storageKey: 'custom_key' } }))
+ expect(result.current.utmParameters).toEqual({ utm_source: 'custom' })
+ })
+ })
describe('capture', () => {
it('captures UTM params from URL', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=test&utm_campaign=sale',
search: '?utm_source=test&utm_campaign=sale',
- });
+ })
- const { result } = renderHook(() =>
- useUtmTracking({ config: { captureOnMount: false } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { captureOnMount: false } }))
act(() => {
- result.current.capture();
- });
+ result.current.capture()
+ })
expect(result.current.utmParameters).toEqual({
utm_source: 'test',
utm_campaign: 'sale',
- });
- expect(result.current.hasParams).toBe(true);
- });
+ })
+ expect(result.current.hasParams).toBe(true)
+ })
it('stores captured params in sessionStorage', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=captured',
search: '?utm_source=captured',
- });
+ })
- const { result } = renderHook(() =>
- useUtmTracking({ config: { captureOnMount: false } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { captureOnMount: false } }))
act(() => {
- result.current.capture();
- });
+ result.current.capture()
+ })
- const stored = sessionStorage.getItem('utm_parameters');
- expect(stored).toBe('{"utm_source":"captured"}');
- });
+ const stored = sessionStorage.getItem('utm_parameters')
+ expect(stored).toBe('{"utm_source":"captured"}')
+ })
it('uses default params when no UTM params in URL', () => {
const { result } = renderHook(() =>
@@ -106,38 +98,38 @@ describe('useUtmTracking', () => {
captureOnMount: false,
defaultParams: { utm_source: 'default' },
},
- })
- );
+ }),
+ )
act(() => {
- result.current.capture();
- });
+ result.current.capture()
+ })
- expect(result.current.utmParameters).toEqual({ utm_source: 'default' });
- });
+ expect(result.current.utmParameters).toEqual({ utm_source: 'default' })
+ })
it('does nothing when disabled', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=test',
search: '?utm_source=test',
- });
+ })
const { result } = renderHook(() =>
- useUtmTracking({ config: { enabled: false, captureOnMount: false } })
- );
+ useUtmTracking({ config: { enabled: false, captureOnMount: false } }),
+ )
act(() => {
- result.current.capture();
- });
+ result.current.capture()
+ })
- expect(result.current.utmParameters).toBeNull();
- });
+ expect(result.current.utmParameters).toBeNull()
+ })
it('respects allowedParameters filter', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=test&utm_campaign=sale&utm_term=keyword',
search: '?utm_source=test&utm_campaign=sale&utm_term=keyword',
- });
+ })
const { result } = renderHook(() =>
useUtmTracking({
@@ -145,108 +137,100 @@ describe('useUtmTracking', () => {
captureOnMount: false,
allowedParameters: ['utm_source', 'utm_campaign'],
},
- })
- );
+ }),
+ )
act(() => {
- result.current.capture();
- });
+ result.current.capture()
+ })
expect(result.current.utmParameters).toEqual({
utm_source: 'test',
utm_campaign: 'sale',
- });
- });
- });
+ })
+ })
+ })
describe('auto-capture on mount', () => {
it('captures on mount when captureOnMount is true', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=auto',
search: '?utm_source=auto',
- });
+ })
- const { result } = renderHook(() =>
- useUtmTracking({ config: { captureOnMount: true } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { captureOnMount: true } }))
// Allow useEffect to run
- expect(result.current.utmParameters).toEqual({ utm_source: 'auto' });
- });
+ expect(result.current.utmParameters).toEqual({ utm_source: 'auto' })
+ })
it('does not capture on mount when captureOnMount is false', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=should_not_capture',
search: '?utm_source=should_not_capture',
- });
+ })
- const { result } = renderHook(() =>
- useUtmTracking({ config: { captureOnMount: false } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { captureOnMount: false } }))
- expect(result.current.utmParameters).toBeNull();
- });
- });
+ expect(result.current.utmParameters).toBeNull()
+ })
+ })
describe('clear', () => {
it('clears stored params', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
- const { result } = renderHook(() => useUtmTracking());
+ const { result } = renderHook(() => useUtmTracking())
- expect(result.current.utmParameters).toEqual({ utm_source: 'test' });
+ expect(result.current.utmParameters).toEqual({ utm_source: 'test' })
act(() => {
- result.current.clear();
- });
+ result.current.clear()
+ })
- expect(result.current.utmParameters).toBeNull();
- expect(result.current.hasParams).toBe(false);
- expect(sessionStorage.getItem('utm_parameters')).toBeNull();
- });
- });
+ expect(result.current.utmParameters).toBeNull()
+ expect(result.current.hasParams).toBe(false)
+ expect(sessionStorage.getItem('utm_parameters')).toBeNull()
+ })
+ })
describe('appendToUrl', () => {
it('appends stored params to URL', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
- const { result } = renderHook(() => useUtmTracking());
+ const { result } = renderHook(() => useUtmTracking())
- const url = result.current.appendToUrl('https://example.com/share');
- expect(url).toBe('https://example.com/share?utm_source=test');
- });
+ const url = result.current.appendToUrl('https://example.com/share')
+ expect(url).toBe('https://example.com/share?utm_source=test')
+ })
it('returns original URL when no params', () => {
- const { result } = renderHook(() => useUtmTracking());
+ const { result } = renderHook(() => useUtmTracking())
- const url = result.current.appendToUrl('https://example.com/share');
- expect(url).toBe('https://example.com/share');
- });
+ const url = result.current.appendToUrl('https://example.com/share')
+ expect(url).toBe('https://example.com/share')
+ })
it('returns original URL when disabled', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
- const { result } = renderHook(() =>
- useUtmTracking({ config: { enabled: false } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { enabled: false } }))
- const url = result.current.appendToUrl('https://example.com/share');
- expect(url).toBe('https://example.com/share');
- });
+ const url = result.current.appendToUrl('https://example.com/share')
+ expect(url).toBe('https://example.com/share')
+ })
it('returns original URL when appendToShares is false', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
- const { result } = renderHook(() =>
- useUtmTracking({ config: { appendToShares: false } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { appendToShares: false } }))
- const url = result.current.appendToUrl('https://example.com/share');
- expect(url).toBe('https://example.com/share');
- });
+ const url = result.current.appendToUrl('https://example.com/share')
+ expect(url).toBe('https://example.com/share')
+ })
it('applies default share context params', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
const { result } = renderHook(() =>
useUtmTracking({
@@ -255,16 +239,16 @@ describe('useUtmTracking', () => {
default: { utm_medium: 'social_share' },
},
},
- })
- );
+ }),
+ )
- const url = result.current.appendToUrl('https://example.com/share');
- expect(url).toContain('utm_source=test');
- expect(url).toContain('utm_medium=social_share');
- });
+ const url = result.current.appendToUrl('https://example.com/share')
+ expect(url).toContain('utm_source=test')
+ expect(url).toContain('utm_medium=social_share')
+ })
it('applies platform-specific params', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
const { result } = renderHook(() =>
useUtmTracking({
@@ -273,16 +257,16 @@ describe('useUtmTracking', () => {
linkedin: { utm_content: 'linkedin_share' },
},
},
- })
- );
+ }),
+ )
- const url = result.current.appendToUrl('https://example.com/share', 'linkedin');
- expect(url).toContain('utm_source=test');
- expect(url).toContain('utm_content=linkedin_share');
- });
+ const url = result.current.appendToUrl('https://example.com/share', 'linkedin')
+ expect(url).toContain('utm_source=test')
+ expect(url).toContain('utm_content=linkedin_share')
+ })
it('platform params override default params', () => {
- sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}');
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test"}')
const { result } = renderHook(() =>
useUtmTracking({
@@ -292,62 +276,57 @@ describe('useUtmTracking', () => {
linkedin: { utm_content: 'linkedin_content' },
},
},
- })
- );
+ }),
+ )
- const url = result.current.appendToUrl('https://example.com/share', 'linkedin');
- expect(url).toContain('utm_content=linkedin_content');
- expect(url).not.toContain('default_content');
- });
+ const url = result.current.appendToUrl('https://example.com/share', 'linkedin')
+ expect(url).toContain('utm_content=linkedin_content')
+ expect(url).not.toContain('default_content')
+ })
it('excludes params in excludeFromShares', () => {
- sessionStorage.setItem(
- 'utm_parameters',
- '{"utm_source":"test","utm_team_id":"team123"}'
- );
+ sessionStorage.setItem('utm_parameters', '{"utm_source":"test","utm_team_id":"team123"}')
const { result } = renderHook(() =>
useUtmTracking({
config: {
excludeFromShares: ['utm_team_id'],
},
- })
- );
+ }),
+ )
- const url = result.current.appendToUrl('https://example.com/share');
- expect(url).toContain('utm_source=test');
- expect(url).not.toContain('utm_team_id');
- expect(url).not.toContain('team123');
- });
- });
+ const url = result.current.appendToUrl('https://example.com/share')
+ expect(url).toContain('utm_source=test')
+ expect(url).not.toContain('utm_team_id')
+ expect(url).not.toContain('team123')
+ })
+ })
describe('key format', () => {
it('uses snake_case by default', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=test',
search: '?utm_source=test',
- });
+ })
- const { result } = renderHook(() =>
- useUtmTracking({ config: { captureOnMount: true } })
- );
+ const { result } = renderHook(() => useUtmTracking({ config: { captureOnMount: true } }))
- expect(result.current.utmParameters).toEqual({ utm_source: 'test' });
- });
+ expect(result.current.utmParameters).toEqual({ utm_source: 'test' })
+ })
it('can use camelCase format', () => {
vi.stubGlobal('location', {
href: 'https://example.com?utm_source=test',
search: '?utm_source=test',
- });
+ })
const { result } = renderHook(() =>
useUtmTracking({
config: { captureOnMount: true, keyFormat: 'camelCase' },
- })
- );
+ }),
+ )
- expect(result.current.utmParameters).toEqual({ utmSource: 'test' });
- });
- });
-});
+ expect(result.current.utmParameters).toEqual({ utmSource: 'test' })
+ })
+ })
+})
diff --git a/__tests__/setup.ts b/__tests__/setup.ts
index e7f0a35..bab1938 100644
--- a/__tests__/setup.ts
+++ b/__tests__/setup.ts
@@ -1,30 +1,30 @@
-import { beforeEach, vi } from 'vitest';
+import { beforeEach, vi } from 'vitest'
// Mock sessionStorage
const createStorageMock = () => {
- let store: Record = {};
+ let store: Record = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
- store[key] = value;
+ store[key] = value
}),
removeItem: vi.fn((key: string) => {
- delete store[key];
+ delete store[key]
}),
clear: vi.fn(() => {
- store = {};
+ store = {}
}),
get length() {
- return Object.keys(store).length;
+ return Object.keys(store).length
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
- };
-};
+ }
+}
// Reset mocks before each test
beforeEach(() => {
- const sessionStorageMock = createStorageMock();
- vi.stubGlobal('sessionStorage', sessionStorageMock);
+ const sessionStorageMock = createStorageMock()
+ vi.stubGlobal('sessionStorage', sessionStorageMock)
// Reset location mock
vi.stubGlobal('location', {
@@ -35,5 +35,5 @@ beforeEach(() => {
protocol: 'https:',
host: 'example.com',
hostname: 'example.com',
- });
-});
+ })
+})
diff --git a/package-lock.json b/package-lock.json
index 1952fc0..a3c467d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@types/react": "^18.0.0",
"@vitest/coverage-v8": "^2.0.0",
"jsdom": "^25.0.0",
+ "prettier": "^3.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"typescript": "^5.0.0",
@@ -2399,6 +2400,22 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prettier": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
+ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
diff --git a/package.json b/package.json
index 2012ea3..39190c4 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,8 @@
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"lint": "tsc --noEmit",
+ "format": "prettier --write \"src/**/*.{ts,tsx}\" \"__tests__/**/*.{ts,tsx}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"__tests__/**/*.{ts,tsx}\"",
"prepublishOnly": "npm run clean && npm run build && npm run test"
},
"peerDependencies": {
@@ -58,6 +60,7 @@
"@types/react": "^18.0.0",
"@vitest/coverage-v8": "^2.0.0",
"jsdom": "^25.0.0",
+ "prettier": "^3.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"typescript": "^5.0.0",
diff --git a/src/config/defaults.ts b/src/config/defaults.ts
index 71afa8e..76875fc 100644
--- a/src/config/defaults.ts
+++ b/src/config/defaults.ts
@@ -4,7 +4,7 @@
* Provides sensible defaults for UTM toolkit configuration.
*/
-import type { ResolvedUtmConfig } from '../types';
+import type { ResolvedUtmConfig } from '../types'
/**
* Standard UTM parameters (snake_case format for URLs)
@@ -16,7 +16,7 @@ export const STANDARD_UTM_PARAMETERS = [
'utm_term',
'utm_content',
'utm_id',
-] as const;
+] as const
/**
* Default configuration with all values set
@@ -49,7 +49,7 @@ export const DEFAULT_CONFIG: ResolvedUtmConfig = {
/** No parameters excluded from shares by default */
excludeFromShares: [],
-};
+}
/**
* Get a copy of the default configuration
@@ -62,5 +62,5 @@ export function getDefaultConfig(): ResolvedUtmConfig {
defaultParams: { ...DEFAULT_CONFIG.defaultParams },
shareContextParams: { ...DEFAULT_CONFIG.shareContextParams },
excludeFromShares: [...DEFAULT_CONFIG.excludeFromShares],
- };
+ }
}
diff --git a/src/config/index.ts b/src/config/index.ts
index e33dcb8..c636fa1 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -2,11 +2,6 @@
* Configuration exports
*/
-export { DEFAULT_CONFIG, STANDARD_UTM_PARAMETERS, getDefaultConfig } from './defaults';
+export { DEFAULT_CONFIG, STANDARD_UTM_PARAMETERS, getDefaultConfig } from './defaults'
-export {
- createConfig,
- mergeConfig,
- loadConfigFromJson,
- validateConfig,
-} from './loader';
+export { createConfig, mergeConfig, loadConfigFromJson, validateConfig } from './loader'
diff --git a/src/config/loader.ts b/src/config/loader.ts
index 6db36c0..b3442b2 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -4,21 +4,21 @@
* Provides utilities for loading and merging UTM toolkit configuration.
*/
-import type { UtmConfig, ResolvedUtmConfig, ShareContextParams, UtmParameters } from '../types';
-import { DEFAULT_CONFIG, getDefaultConfig } from './defaults';
+import type { UtmConfig, ResolvedUtmConfig, ShareContextParams, UtmParameters } from '../types'
+import { DEFAULT_CONFIG, getDefaultConfig } from './defaults'
/**
* Deep merge share context params
*/
function mergeShareContextParams(
base: ShareContextParams,
- override: ShareContextParams | undefined
+ override: ShareContextParams | undefined,
): ShareContextParams {
if (!override) {
- return { ...base };
+ return { ...base }
}
- const result: ShareContextParams = { ...base };
+ const result: ShareContextParams = { ...base }
for (const [key, value] of Object.entries(override)) {
if (value !== undefined) {
@@ -26,24 +26,21 @@ function mergeShareContextParams(
result[key] = {
...(base[key] || {}),
...value,
- };
+ }
}
}
- return result;
+ return result
}
/**
* Merge two UTM parameter objects
*/
-function mergeUtmParams(
- base: UtmParameters,
- override: UtmParameters | undefined
-): UtmParameters {
+function mergeUtmParams(base: UtmParameters, override: UtmParameters | undefined): UtmParameters {
if (!override) {
- return { ...base };
+ return { ...base }
}
- return { ...base, ...override };
+ return { ...base, ...override }
}
/**
@@ -62,10 +59,10 @@ function mergeUtmParams(
* ```
*/
export function createConfig(userConfig?: Partial): ResolvedUtmConfig {
- const defaults = getDefaultConfig();
+ const defaults = getDefaultConfig()
if (!userConfig) {
- return defaults;
+ return defaults
}
return {
@@ -80,12 +77,12 @@ export function createConfig(userConfig?: Partial): ResolvedUtmConfig
defaultParams: mergeUtmParams(defaults.defaultParams, userConfig.defaultParams),
shareContextParams: mergeShareContextParams(
defaults.shareContextParams,
- userConfig.shareContextParams
+ userConfig.shareContextParams,
),
excludeFromShares: userConfig.excludeFromShares
? [...userConfig.excludeFromShares]
: defaults.excludeFromShares,
- };
+ }
}
/**
@@ -97,7 +94,7 @@ export function createConfig(userConfig?: Partial): ResolvedUtmConfig
*/
export function mergeConfig(
base: ResolvedUtmConfig,
- override: Partial
+ override: Partial,
): ResolvedUtmConfig {
return {
enabled: override.enabled ?? base.enabled,
@@ -111,12 +108,12 @@ export function mergeConfig(
defaultParams: mergeUtmParams(base.defaultParams, override.defaultParams),
shareContextParams: mergeShareContextParams(
base.shareContextParams,
- override.shareContextParams
+ override.shareContextParams,
),
excludeFromShares: override.excludeFromShares
? [...override.excludeFromShares]
: [...base.excludeFromShares],
- };
+ }
}
/**
@@ -141,12 +138,12 @@ export function mergeConfig(
*/
export function loadConfigFromJson(jsonConfig: unknown): ResolvedUtmConfig {
if (!jsonConfig || typeof jsonConfig !== 'object' || Array.isArray(jsonConfig)) {
- console.warn('Invalid UTM config JSON, using defaults');
- return getDefaultConfig();
+ console.warn('Invalid UTM config JSON, using defaults')
+ return getDefaultConfig()
}
// Cast to partial config and let createConfig handle validation
- return createConfig(jsonConfig as Partial);
+ return createConfig(jsonConfig as Partial)
}
/**
@@ -159,62 +156,72 @@ export function loadConfigFromJson(jsonConfig: unknown): ResolvedUtmConfig {
* @returns Array of validation error messages (empty if valid)
*/
export function validateConfig(config: unknown): string[] {
- const errors: string[] = [];
+ const errors: string[] = []
if (!config || typeof config !== 'object' || Array.isArray(config)) {
- return ['Config must be a non-null object'];
+ return ['Config must be a non-null object']
}
- const c = config as Record;
+ const c = config as Record
if (c.enabled !== undefined && typeof c.enabled !== 'boolean') {
- errors.push('enabled must be a boolean');
+ errors.push('enabled must be a boolean')
}
if (c.keyFormat !== undefined && c.keyFormat !== 'snake_case' && c.keyFormat !== 'camelCase') {
- errors.push('keyFormat must be "snake_case" or "camelCase"');
+ errors.push('keyFormat must be "snake_case" or "camelCase"')
}
if (c.storageKey !== undefined && typeof c.storageKey !== 'string') {
- errors.push('storageKey must be a string');
+ errors.push('storageKey must be a string')
}
if (c.captureOnMount !== undefined && typeof c.captureOnMount !== 'boolean') {
- errors.push('captureOnMount must be a boolean');
+ errors.push('captureOnMount must be a boolean')
}
if (c.appendToShares !== undefined && typeof c.appendToShares !== 'boolean') {
- errors.push('appendToShares must be a boolean');
+ errors.push('appendToShares must be a boolean')
}
if (c.allowedParameters !== undefined) {
if (!Array.isArray(c.allowedParameters)) {
- errors.push('allowedParameters must be an array');
+ errors.push('allowedParameters must be an array')
} else if (!c.allowedParameters.every((p) => typeof p === 'string')) {
- errors.push('allowedParameters must contain only strings');
+ errors.push('allowedParameters must contain only strings')
}
}
if (c.excludeFromShares !== undefined) {
if (!Array.isArray(c.excludeFromShares)) {
- errors.push('excludeFromShares must be an array');
+ errors.push('excludeFromShares must be an array')
} else if (!c.excludeFromShares.every((p) => typeof p === 'string')) {
- errors.push('excludeFromShares must contain only strings');
+ errors.push('excludeFromShares must contain only strings')
}
}
- if (c.defaultParams !== undefined && (typeof c.defaultParams !== 'object' || c.defaultParams === null || Array.isArray(c.defaultParams))) {
- errors.push('defaultParams must be an object');
+ if (
+ c.defaultParams !== undefined &&
+ (typeof c.defaultParams !== 'object' ||
+ c.defaultParams === null ||
+ Array.isArray(c.defaultParams))
+ ) {
+ errors.push('defaultParams must be an object')
}
- if (c.shareContextParams !== undefined && (typeof c.shareContextParams !== 'object' || c.shareContextParams === null || Array.isArray(c.shareContextParams))) {
- errors.push('shareContextParams must be an object');
+ if (
+ c.shareContextParams !== undefined &&
+ (typeof c.shareContextParams !== 'object' ||
+ c.shareContextParams === null ||
+ Array.isArray(c.shareContextParams))
+ ) {
+ errors.push('shareContextParams must be an object')
}
- return errors;
+ return errors
}
/**
* Re-export default config for convenience
*/
-export { DEFAULT_CONFIG, getDefaultConfig };
+export { DEFAULT_CONFIG, getDefaultConfig }
diff --git a/src/core/appender.ts b/src/core/appender.ts
index 0313bc8..3d9303f 100644
--- a/src/core/appender.ts
+++ b/src/core/appender.ts
@@ -5,8 +5,8 @@
* Supports both query string and fragment (hash) parameter placement.
*/
-import type { AppendOptions, UtmParameters } from '../types';
-import { toSnakeCaseParams, isSnakeCaseUtmKey, isCamelCaseUtmKey } from './keys';
+import type { AppendOptions, UtmParameters } from '../types'
+import { toSnakeCaseParams, isSnakeCaseUtmKey, isCamelCaseUtmKey } from './keys'
/**
* Builds a query string with proper handling of empty parameter values
@@ -18,26 +18,26 @@ import { toSnakeCaseParams, isSnakeCaseUtmKey, isCamelCaseUtmKey } from './keys'
* @returns The formatted query string (without leading ?)
*/
function buildQueryString(params: URLSearchParams): string {
- const pairs: string[] = [];
+ const pairs: string[] = []
for (const [key, value] of params.entries()) {
if (value === '') {
// Empty values: include key only, no equals sign
- pairs.push(encodeURIComponent(key));
+ pairs.push(encodeURIComponent(key))
} else {
// Standard key-value pairs with proper URL encoding
- pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
}
}
- return pairs.join('&');
+ return pairs.join('&')
}
/**
* Check if a key is a UTM key (in either format)
*/
function isAnyUtmKey(key: string): boolean {
- return isSnakeCaseUtmKey(key) || isCamelCaseUtmKey(key);
+ return isSnakeCaseUtmKey(key) || isCamelCaseUtmKey(key)
}
/**
@@ -45,8 +45,8 @@ function isAnyUtmKey(key: string): boolean {
*/
function hasValidUtmEntries(params: UtmParameters): boolean {
return Object.entries(params).some(
- ([key, value]) => isAnyUtmKey(key) && value !== undefined && value !== ''
- );
+ ([key, value]) => isAnyUtmKey(key) && value !== undefined && value !== '',
+ )
}
/**
@@ -98,134 +98,126 @@ function hasValidUtmEntries(params: UtmParameters): boolean {
export function appendUtmParameters(
url: string,
utmParams: UtmParameters,
- options: AppendOptions = {}
+ options: AppendOptions = {},
): string {
- const { toFragment = false, preserveExisting = false } = options;
+ const { toFragment = false, preserveExisting = false } = options
// Fast-path: nothing to append
if (!hasValidUtmEntries(utmParams)) {
- return url;
+ return url
}
// SSR safety check
if (typeof URL === 'undefined') {
- return url;
+ return url
}
try {
// Convert all params to snake_case for URL usage
- const snakeParams = toSnakeCaseParams(utmParams);
+ const snakeParams = toSnakeCaseParams(utmParams)
// Parse the URL
- const urlObj = new URL(url);
+ const urlObj = new URL(url)
if (toFragment) {
// === FRAGMENT-BASED PARAMETER ADDITION ===
- return appendToFragment(urlObj, snakeParams, preserveExisting);
+ return appendToFragment(urlObj, snakeParams, preserveExisting)
} else {
// === QUERY-BASED PARAMETER ADDITION (DEFAULT) ===
- return appendToQuery(urlObj, snakeParams, preserveExisting);
+ return appendToQuery(urlObj, snakeParams, preserveExisting)
}
} catch (error) {
// If URL parsing fails, return the original URL unchanged
if (typeof console !== 'undefined' && console.warn) {
- console.warn('Failed to append UTM parameters to URL:', error);
+ console.warn('Failed to append UTM parameters to URL:', error)
}
- return url;
+ return url
}
}
/**
* Append UTM parameters to URL query string
*/
-function appendToQuery(
- urlObj: URL,
- params: UtmParameters,
- preserveExisting: boolean
-): string {
+function appendToQuery(urlObj: URL, params: UtmParameters, preserveExisting: boolean): string {
// When adding to query, handle conflicting parameters in fragment
if (urlObj.hash) {
- const originalFragment = urlObj.hash.substring(1);
+ const originalFragment = urlObj.hash.substring(1)
// Only process fragment as parameters if it looks like parameters (contains '=')
// Regular fragments (like #section) should be left alone
if (originalFragment.includes('=')) {
- const fragmentParams = new URLSearchParams(originalFragment);
+ const fragmentParams = new URLSearchParams(originalFragment)
// Remove tracking parameters from fragment (newer location wins)
for (const key of Object.keys(params)) {
- fragmentParams.delete(key);
+ fragmentParams.delete(key)
}
// Update fragment, clearing it if no parameters remain
- urlObj.hash = fragmentParams.toString() || '';
+ urlObj.hash = fragmentParams.toString() || ''
}
}
// Process query parameters
for (const [key, value] of Object.entries(params)) {
- if (value === undefined) continue;
+ if (value === undefined) continue
if (preserveExisting && urlObj.searchParams.has(key)) {
// Skip if preserving existing and param already exists
- continue;
+ continue
}
// Remove existing parameter first to avoid duplicates
- urlObj.searchParams.delete(key);
+ urlObj.searchParams.delete(key)
// Add the new parameter
- urlObj.searchParams.set(key, value);
+ urlObj.searchParams.set(key, value)
}
// Manually rebuild URL to handle empty parameter values correctly
- const queryString = buildQueryString(urlObj.searchParams);
- const baseUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`;
+ const queryString = buildQueryString(urlObj.searchParams)
+ const baseUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
// Construct final URL with proper fragment handling
- const hash = urlObj.hash;
+ const hash = urlObj.hash
const finalUrl =
baseUrl +
(queryString ? `?${queryString}` : '') +
- (hash ? (hash.startsWith('#') ? hash : `#${hash}`) : '');
+ (hash ? (hash.startsWith('#') ? hash : `#${hash}`) : '')
- return finalUrl;
+ return finalUrl
}
/**
* Append UTM parameters to URL fragment (hash)
*/
-function appendToFragment(
- urlObj: URL,
- params: UtmParameters,
- preserveExisting: boolean
-): string {
+function appendToFragment(urlObj: URL, params: UtmParameters, preserveExisting: boolean): string {
// Remove conflicting parameters from query string (newer location wins)
for (const key of Object.keys(params)) {
- urlObj.searchParams.delete(key);
+ urlObj.searchParams.delete(key)
}
// Parse existing fragment as parameters
- const fragmentParams = new URLSearchParams(urlObj.hash.substring(1));
+ const fragmentParams = new URLSearchParams(urlObj.hash.substring(1))
// Process fragment parameters
for (const [key, value] of Object.entries(params)) {
- if (value === undefined) continue;
+ if (value === undefined) continue
if (preserveExisting && fragmentParams.has(key)) {
// Skip if preserving existing and param already exists
- continue;
+ continue
}
// Remove existing parameter first to avoid duplicates
- fragmentParams.delete(key);
+ fragmentParams.delete(key)
// Add the new parameter
- fragmentParams.set(key, value);
+ fragmentParams.set(key, value)
}
// Build the fragment string
- urlObj.hash = buildQueryString(fragmentParams);
+ urlObj.hash = buildQueryString(fragmentParams)
- return urlObj.toString();
+ return urlObj.toString()
}
/**
@@ -253,51 +245,51 @@ function appendToFragment(
*/
export function removeUtmParameters(url: string, keysToRemove?: string[]): string {
if (typeof URL === 'undefined') {
- return url;
+ return url
}
try {
- const urlObj = new URL(url);
+ const urlObj = new URL(url)
// Remove from query string
- const queryKeysToDelete: string[] = [];
+ const queryKeysToDelete: string[] = []
for (const key of urlObj.searchParams.keys()) {
if (keysToRemove) {
if (keysToRemove.includes(key)) {
- queryKeysToDelete.push(key);
+ queryKeysToDelete.push(key)
}
} else if (isSnakeCaseUtmKey(key)) {
- queryKeysToDelete.push(key);
+ queryKeysToDelete.push(key)
}
}
for (const key of queryKeysToDelete) {
- urlObj.searchParams.delete(key);
+ urlObj.searchParams.delete(key)
}
// Remove from fragment if it contains parameters
if (urlObj.hash && urlObj.hash.includes('=')) {
- const fragmentParams = new URLSearchParams(urlObj.hash.substring(1));
- const fragmentKeysToDelete: string[] = [];
+ const fragmentParams = new URLSearchParams(urlObj.hash.substring(1))
+ const fragmentKeysToDelete: string[] = []
for (const key of fragmentParams.keys()) {
if (keysToRemove) {
if (keysToRemove.includes(key)) {
- fragmentKeysToDelete.push(key);
+ fragmentKeysToDelete.push(key)
}
} else if (isSnakeCaseUtmKey(key)) {
- fragmentKeysToDelete.push(key);
+ fragmentKeysToDelete.push(key)
}
}
for (const key of fragmentKeysToDelete) {
- fragmentParams.delete(key);
+ fragmentParams.delete(key)
}
- urlObj.hash = fragmentParams.toString();
+ urlObj.hash = fragmentParams.toString()
}
- return urlObj.toString();
+ return urlObj.toString()
} catch {
- return url;
+ return url
}
}
@@ -309,33 +301,33 @@ export function removeUtmParameters(url: string, keysToRemove?: string[]): strin
*/
export function extractUtmParameters(url: string): UtmParameters {
if (typeof URL === 'undefined') {
- return {};
+ return {}
}
try {
- const urlObj = new URL(url);
- const params: Record = {};
+ const urlObj = new URL(url)
+ const params: Record = {}
// Extract from query string
for (const [key, value] of urlObj.searchParams.entries()) {
if (isSnakeCaseUtmKey(key)) {
- params[key] = value;
+ params[key] = value
}
}
// Extract from fragment if it contains parameters
if (urlObj.hash && urlObj.hash.includes('=')) {
- const fragmentParams = new URLSearchParams(urlObj.hash.substring(1));
+ const fragmentParams = new URLSearchParams(urlObj.hash.substring(1))
for (const [key, value] of fragmentParams.entries()) {
if (isSnakeCaseUtmKey(key)) {
// Fragment params override query params (later in URL = higher priority)
- params[key] = value;
+ params[key] = value
}
}
}
- return params as UtmParameters;
+ return params as UtmParameters
} catch {
- return {};
+ return {}
}
}
diff --git a/src/core/capture.ts b/src/core/capture.ts
index ff99026..be3eec5 100644
--- a/src/core/capture.ts
+++ b/src/core/capture.ts
@@ -5,25 +5,25 @@
* Supports standard UTM parameters and custom utm_ prefixed parameters.
*/
-import type { KeyFormat, UtmParameters } from '../types';
-import { convertParams, isSnakeCaseUtmKey } from './keys';
+import type { KeyFormat, UtmParameters } from '../types'
+import { convertParams, isSnakeCaseUtmKey } from './keys'
/**
* Options for capturing UTM parameters
*/
export interface CaptureOptions {
/** Target key format for returned parameters (default: 'snake_case') */
- keyFormat?: KeyFormat;
+ keyFormat?: KeyFormat
/** Allowlist of parameters to capture (snake_case format, e.g., ['utm_source', 'utm_campaign']) */
- allowedParameters?: string[];
+ allowedParameters?: string[]
}
/**
* Check if we're in a browser environment with access to window
*/
function isBrowser(): boolean {
- return typeof window !== 'undefined' && typeof window.location !== 'undefined';
+ return typeof window !== 'undefined' && typeof window.location !== 'undefined'
}
/**
@@ -58,28 +58,25 @@ function isBrowser(): boolean {
* // Returns: { utm_source: 'linkedin', utm_campaign: 'test' }
* ```
*/
-export function captureUtmParameters(
- url?: string,
- options: CaptureOptions = {}
-): UtmParameters {
- const { keyFormat = 'snake_case', allowedParameters } = options;
+export function captureUtmParameters(url?: string, options: CaptureOptions = {}): UtmParameters {
+ const { keyFormat = 'snake_case', allowedParameters } = options
// Get URL, defaulting to current page URL in browser
- const urlString = url ?? (isBrowser() ? window.location.href : '');
+ const urlString = url ?? (isBrowser() ? window.location.href : '')
// SSR safety: return empty object if no URL available
if (!urlString) {
- return {};
+ return {}
}
try {
// Parse the URL to extract query parameters
- const urlObj = new URL(urlString);
- const params: Record = {};
+ const urlObj = new URL(urlString)
+ const params: Record = {}
// Create a set of allowed parameters for O(1) lookup
const allowedSet =
- allowedParameters && allowedParameters.length > 0 ? new Set(allowedParameters) : null;
+ allowedParameters && allowedParameters.length > 0 ? new Set(allowedParameters) : null
// Iterate through all query parameters
for (const [key, value] of urlObj.searchParams.entries()) {
@@ -87,27 +84,27 @@ export function captureUtmParameters(
if (isSnakeCaseUtmKey(key)) {
// If allowedParameters is provided, check if this parameter is allowed
if (allowedSet === null || allowedSet.has(key)) {
- params[key] = value;
+ params[key] = value
}
}
}
// Convert to target format if needed
if (keyFormat === 'camelCase') {
- return convertParams(params as UtmParameters, 'camelCase');
+ return convertParams(params as UtmParameters, 'camelCase')
}
- return params as UtmParameters;
+ return params as UtmParameters
} catch (error) {
// If URL parsing fails, return empty object
// This ensures the function is robust and doesn't break the app
if (typeof console !== 'undefined' && console.warn) {
console.warn(
'Failed to parse URL for UTM parameters:',
- error instanceof Error ? error.message : 'Unknown error'
- );
+ error instanceof Error ? error.message : 'Unknown error',
+ )
}
- return {};
+ return {}
}
}
@@ -127,12 +124,12 @@ export function captureUtmParameters(
*/
export function hasUtmParameters(params: UtmParameters | null | undefined): boolean {
if (!params || typeof params !== 'object') {
- return false;
+ return false
}
return Object.values(params).some(
- (value) => value !== undefined && value !== null && value !== ''
- );
+ (value) => value !== undefined && value !== null && value !== '',
+ )
}
/**
@@ -143,7 +140,7 @@ export function hasUtmParameters(params: UtmParameters | null | undefined): bool
* @returns UTM parameters from current URL, or empty object if SSR
*/
export function captureFromCurrentUrl(options: CaptureOptions = {}): UtmParameters {
- return captureUtmParameters(undefined, options);
+ return captureUtmParameters(undefined, options)
}
/**
@@ -154,14 +151,12 @@ export function captureFromCurrentUrl(options: CaptureOptions = {}): UtmParamete
* @returns Object with utm parameters and referrer
*/
export function captureWithReferrer(options: CaptureOptions = {}): {
- params: UtmParameters;
- referrer: string | null;
+ params: UtmParameters
+ referrer: string | null
} {
- const params = captureFromCurrentUrl(options);
+ const params = captureFromCurrentUrl(options)
const referrer =
- isBrowser() && typeof document !== 'undefined' && document.referrer
- ? document.referrer
- : null;
+ isBrowser() && typeof document !== 'undefined' && document.referrer ? document.referrer : null
- return { params, referrer };
+ return { params, referrer }
}
diff --git a/src/core/index.ts b/src/core/index.ts
index 203c69f..6a54c78 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -11,7 +11,7 @@ export {
captureFromCurrentUrl,
captureWithReferrer,
type CaptureOptions,
-} from './capture';
+} from './capture'
// Storage utilities
export {
@@ -23,14 +23,10 @@ export {
getRawStoredValue,
DEFAULT_STORAGE_KEY,
type StorageOptions,
-} from './storage';
+} from './storage'
// Appender utilities
-export {
- appendUtmParameters,
- removeUtmParameters,
- extractUtmParameters,
-} from './appender';
+export { appendUtmParameters, removeUtmParameters, extractUtmParameters } from './appender'
// Key conversion utilities
export {
@@ -50,7 +46,7 @@ export {
CAMEL_TO_SNAKE,
STANDARD_SNAKE_KEYS,
STANDARD_CAMEL_KEYS,
-} from './keys';
+} from './keys'
// Validator utilities
export {
@@ -64,4 +60,4 @@ export {
isProtocolAllowed,
getErrorMessage,
ERROR_MESSAGES,
-} from './validator';
+} from './validator'
diff --git a/src/core/keys.ts b/src/core/keys.ts
index 842fdea..b7d164a 100644
--- a/src/core/keys.ts
+++ b/src/core/keys.ts
@@ -5,7 +5,7 @@
* and camelCase (TypeScript format) for UTM parameters.
*/
-import type { KeyFormat, UtmParameters, UtmParametersSnake, UtmParametersCamel } from '../types';
+import type { KeyFormat, UtmParameters, UtmParametersSnake, UtmParametersCamel } from '../types'
/**
* Standard UTM parameter mappings: snake_case -> camelCase
@@ -17,7 +17,7 @@ export const SNAKE_TO_CAMEL: Record = {
utm_term: 'utmTerm',
utm_content: 'utmContent',
utm_id: 'utmId',
-};
+}
/**
* Standard UTM parameter mappings: camelCase -> snake_case
@@ -29,17 +29,17 @@ export const CAMEL_TO_SNAKE: Record = {
utmTerm: 'utm_term',
utmContent: 'utm_content',
utmId: 'utm_id',
-};
+}
/**
* All standard snake_case UTM keys
*/
-export const STANDARD_SNAKE_KEYS = Object.keys(SNAKE_TO_CAMEL);
+export const STANDARD_SNAKE_KEYS = Object.keys(SNAKE_TO_CAMEL)
/**
* All standard camelCase UTM keys
*/
-export const STANDARD_CAMEL_KEYS = Object.keys(CAMEL_TO_SNAKE);
+export const STANDARD_CAMEL_KEYS = Object.keys(CAMEL_TO_SNAKE)
/**
* Convert a single key from snake_case to camelCase
@@ -51,22 +51,20 @@ export const STANDARD_CAMEL_KEYS = Object.keys(CAMEL_TO_SNAKE);
export function toSnakeCase(key: string): string {
// Check standard mappings first
if (key in CAMEL_TO_SNAKE) {
- return CAMEL_TO_SNAKE[key]!;
+ return CAMEL_TO_SNAKE[key]!
}
// For custom keys: utmTeamId -> utm_team_id
// Handle keys that start with 'utm' prefix
if (key.startsWith('utm') && key.length > 3) {
- const rest = key.slice(3); // Remove 'utm' prefix
+ const rest = key.slice(3) // Remove 'utm' prefix
// Convert camelCase to snake_case
- const snakeRest = rest
- .replace(/([A-Z])/g, '_$1')
- .toLowerCase();
- return `utm${snakeRest}`;
+ const snakeRest = rest.replace(/([A-Z])/g, '_$1').toLowerCase()
+ return `utm${snakeRest}`
}
// If already snake_case or doesn't start with utm, return as-is
- return key;
+ return key
}
/**
@@ -79,26 +77,26 @@ export function toSnakeCase(key: string): string {
export function toCamelCase(key: string): string {
// Check standard mappings first
if (key in SNAKE_TO_CAMEL) {
- return SNAKE_TO_CAMEL[key]!;
+ return SNAKE_TO_CAMEL[key]!
}
// For custom keys: utm_team_id -> utmTeamId
if (key.startsWith('utm_') && key.length > 4) {
- const rest = key.slice(4); // Remove 'utm_' prefix
+ const rest = key.slice(4) // Remove 'utm_' prefix
// Convert snake_case to camelCase
- const camelRest = rest.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
- return `utm${camelRest.charAt(0).toUpperCase()}${camelRest.slice(1)}`;
+ const camelRest = rest.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase())
+ return `utm${camelRest.charAt(0).toUpperCase()}${camelRest.slice(1)}`
}
// If already camelCase or doesn't start with utm_, return as-is
- return key;
+ return key
}
/**
* Check if a key is in snake_case UTM format (starts with 'utm_')
*/
export function isSnakeCaseUtmKey(key: string): boolean {
- return key.startsWith('utm_');
+ return key.startsWith('utm_')
}
/**
@@ -106,18 +104,18 @@ export function isSnakeCaseUtmKey(key: string): boolean {
*/
export function isCamelCaseUtmKey(key: string): boolean {
if (!key.startsWith('utm') || key.length <= 3) {
- return false;
+ return false
}
- const fourthChar = key[3];
+ const fourthChar = key[3]
// Must be an uppercase letter (A-Z), not underscore or other character
- return fourthChar !== undefined && /^[A-Z]$/.test(fourthChar);
+ return fourthChar !== undefined && /^[A-Z]$/.test(fourthChar)
}
/**
* Check if a key is a valid UTM key in either format
*/
export function isUtmKey(key: string): boolean {
- return isSnakeCaseUtmKey(key) || isCamelCaseUtmKey(key);
+ return isSnakeCaseUtmKey(key) || isCamelCaseUtmKey(key)
}
/**
@@ -127,18 +125,18 @@ export function isUtmKey(key: string): boolean {
* Returns 'snake_case' as default for empty objects
*/
export function detectKeyFormat(params: UtmParameters): KeyFormat {
- const keys = Object.keys(params);
+ const keys = Object.keys(params)
for (const key of keys) {
if (isSnakeCaseUtmKey(key)) {
- return 'snake_case';
+ return 'snake_case'
}
if (isCamelCaseUtmKey(key)) {
- return 'camelCase';
+ return 'camelCase'
}
}
- return 'snake_case'; // Default
+ return 'snake_case' // Default
}
/**
@@ -148,35 +146,32 @@ export function detectKeyFormat(params: UtmParameters): KeyFormat {
* @param targetFormat - Target key format ('snake_case' or 'camelCase')
* @returns New object with converted keys
*/
-export function convertParams(
- params: UtmParameters,
- targetFormat: KeyFormat
-): UtmParameters {
- const result: Record = {};
- const converter = targetFormat === 'snake_case' ? toSnakeCase : toCamelCase;
+export function convertParams(params: UtmParameters, targetFormat: KeyFormat): UtmParameters {
+ const result: Record = {}
+ const converter = targetFormat === 'snake_case' ? toSnakeCase : toCamelCase
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
- const newKey = converter(key);
- result[newKey] = value;
+ const newKey = converter(key)
+ result[newKey] = value
}
}
- return result as UtmParameters;
+ return result as UtmParameters
}
/**
* Convert parameters to snake_case format
*/
export function toSnakeCaseParams(params: UtmParameters): UtmParametersSnake {
- return convertParams(params, 'snake_case') as UtmParametersSnake;
+ return convertParams(params, 'snake_case') as UtmParametersSnake
}
/**
* Convert parameters to camelCase format
*/
export function toCamelCaseParams(params: UtmParameters): UtmParametersCamel {
- return convertParams(params, 'camelCase') as UtmParametersCamel;
+ return convertParams(params, 'camelCase') as UtmParametersCamel
}
/**
@@ -186,38 +181,35 @@ export function toCamelCaseParams(params: UtmParameters): UtmParametersCamel {
* @param format - Expected key format (optional, will detect if not provided)
* @returns True if object is valid UTM parameters
*/
-export function isValidUtmParameters(
- obj: unknown,
- format?: KeyFormat
-): obj is UtmParameters {
+export function isValidUtmParameters(obj: unknown, format?: KeyFormat): obj is UtmParameters {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
- return false;
+ return false
}
- const entries = Object.entries(obj);
+ const entries = Object.entries(obj)
// Empty object is valid
if (entries.length === 0) {
- return true;
+ return true
}
return entries.every(([key, value]) => {
// Value must be string or undefined
if (value !== undefined && typeof value !== 'string') {
- return false;
+ return false
}
// Key must be valid UTM key
if (format === 'snake_case') {
- return isSnakeCaseUtmKey(key);
+ return isSnakeCaseUtmKey(key)
}
if (format === 'camelCase') {
- return isCamelCaseUtmKey(key);
+ return isCamelCaseUtmKey(key)
}
// If no format specified, accept either
- return isUtmKey(key);
- });
+ return isUtmKey(key)
+ })
}
/**
@@ -226,9 +218,9 @@ export function isValidUtmParameters(
*/
export function normalizeKey(key: string, targetFormat: KeyFormat): string {
if (targetFormat === 'snake_case') {
- return isSnakeCaseUtmKey(key) ? key : toSnakeCase(key);
+ return isSnakeCaseUtmKey(key) ? key : toSnakeCase(key)
}
- return isCamelCaseUtmKey(key) ? key : toCamelCase(key);
+ return isCamelCaseUtmKey(key) ? key : toCamelCase(key)
}
/**
@@ -236,5 +228,5 @@ export function normalizeKey(key: string, targetFormat: KeyFormat): string {
* Always returns snake_case regardless of input format
*/
export function toUrlKey(key: string): string {
- return isSnakeCaseUtmKey(key) ? key : toSnakeCase(key);
+ return isSnakeCaseUtmKey(key) ? key : toSnakeCase(key)
}
diff --git a/src/core/storage.ts b/src/core/storage.ts
index 4888d0c..e0003f1 100644
--- a/src/core/storage.ts
+++ b/src/core/storage.ts
@@ -6,23 +6,23 @@
* cleared when the browser/tab is closed.
*/
-import type { KeyFormat, UtmParameters } from '../types';
-import { convertParams, isSnakeCaseUtmKey, isCamelCaseUtmKey, isUtmKey } from './keys';
+import type { KeyFormat, UtmParameters } from '../types'
+import { convertParams, isSnakeCaseUtmKey, isCamelCaseUtmKey, isUtmKey } from './keys'
/**
* Default storage key for UTM parameters in sessionStorage
*/
-export const DEFAULT_STORAGE_KEY = 'utm_parameters';
+export const DEFAULT_STORAGE_KEY = 'utm_parameters'
/**
* Options for storage operations
*/
export interface StorageOptions {
/** Storage key to use (default: 'utm_parameters') */
- storageKey?: string;
+ storageKey?: string
/** Key format to store parameters in (default: 'snake_case') */
- keyFormat?: KeyFormat;
+ keyFormat?: KeyFormat
}
/**
@@ -31,15 +31,15 @@ export interface StorageOptions {
function isStorageAvailable(): boolean {
try {
if (typeof sessionStorage === 'undefined') {
- return false;
+ return false
}
// Test write/read to ensure it's actually functional
- const testKey = '__utm_test__';
- sessionStorage.setItem(testKey, 'test');
- sessionStorage.removeItem(testKey);
- return true;
+ const testKey = '__utm_test__'
+ sessionStorage.setItem(testKey, 'test')
+ sessionStorage.removeItem(testKey)
+ return true
} catch {
- return false;
+ return false
}
}
@@ -49,33 +49,33 @@ function isStorageAvailable(): boolean {
function isValidStoredData(data: unknown, keyFormat?: KeyFormat): data is UtmParameters {
// Must be a non-null, non-array object
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
- return false;
+ return false
}
- const entries = Object.entries(data);
+ const entries = Object.entries(data)
// Empty object is valid
if (entries.length === 0) {
- return true;
+ return true
}
return entries.every(([key, value]) => {
// Value must be string or undefined
if (value !== undefined && typeof value !== 'string') {
- return false;
+ return false
}
// Validate key format
if (keyFormat === 'snake_case') {
- return isSnakeCaseUtmKey(key);
+ return isSnakeCaseUtmKey(key)
}
if (keyFormat === 'camelCase') {
- return isCamelCaseUtmKey(key);
+ return isCamelCaseUtmKey(key)
}
// Accept either format if not specified
- return isUtmKey(key);
- });
+ return isUtmKey(key)
+ })
}
/**
@@ -107,27 +107,24 @@ function isValidStoredData(data: unknown, keyFormat?: KeyFormat): data is UtmPar
* )
* ```
*/
-export function storeUtmParameters(
- params: UtmParameters,
- options: StorageOptions = {}
-): void {
- const { storageKey = DEFAULT_STORAGE_KEY, keyFormat = 'snake_case' } = options;
+export function storeUtmParameters(params: UtmParameters, options: StorageOptions = {}): void {
+ const { storageKey = DEFAULT_STORAGE_KEY, keyFormat = 'snake_case' } = options
// SSR safety
if (!isStorageAvailable()) {
- return;
+ return
}
try {
// Skip storing if params is empty
if (Object.keys(params).length === 0) {
- return;
+ return
}
// Convert to target format before storing
- const paramsToStore = convertParams(params, keyFormat);
- const serialized = JSON.stringify(paramsToStore);
- sessionStorage.setItem(storageKey, serialized);
+ const paramsToStore = convertParams(params, keyFormat)
+ const serialized = JSON.stringify(paramsToStore)
+ sessionStorage.setItem(storageKey, serialized)
} catch (error) {
// Fail silently - storage errors should not break the app
// Common causes:
@@ -135,7 +132,7 @@ export function storeUtmParameters(
// - SecurityError (storage access denied)
// - Circular reference in params (JSON.stringify fails)
if (typeof console !== 'undefined' && console.warn) {
- console.warn('Failed to store UTM parameters:', error);
+ console.warn('Failed to store UTM parameters:', error)
}
}
}
@@ -166,48 +163,46 @@ export function storeUtmParameters(
* // Returns: { utmSource: '...', utmCampaign: '...' }
* ```
*/
-export function getStoredUtmParameters(
- options: StorageOptions = {}
-): UtmParameters | null {
- const { storageKey = DEFAULT_STORAGE_KEY, keyFormat } = options;
+export function getStoredUtmParameters(options: StorageOptions = {}): UtmParameters | null {
+ const { storageKey = DEFAULT_STORAGE_KEY, keyFormat } = options
// SSR safety
if (!isStorageAvailable()) {
- return null;
+ return null
}
try {
- const stored = sessionStorage.getItem(storageKey);
+ const stored = sessionStorage.getItem(storageKey)
if (stored === null) {
- return null;
+ return null
}
- const parsed: unknown = JSON.parse(stored);
+ const parsed: unknown = JSON.parse(stored)
// Validate the parsed data
if (!isValidStoredData(parsed)) {
if (typeof console !== 'undefined' && console.warn) {
- console.warn('Stored UTM data is invalid, ignoring');
+ console.warn('Stored UTM data is invalid, ignoring')
}
- return null;
+ return null
}
// Convert to requested format if specified
if (keyFormat) {
- return convertParams(parsed, keyFormat);
+ return convertParams(parsed, keyFormat)
}
- return parsed;
+ return parsed
} catch (error) {
// Fail silently and return null
// Common causes:
// - JSON.parse error (invalid JSON)
// - SecurityError (storage access denied)
if (typeof console !== 'undefined' && console.warn) {
- console.warn('Failed to retrieve stored UTM parameters:', error);
+ console.warn('Failed to retrieve stored UTM parameters:', error)
}
- return null;
+ return null
}
}
@@ -231,15 +226,15 @@ export function getStoredUtmParameters(
export function clearStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KEY): void {
// SSR safety
if (!isStorageAvailable()) {
- return;
+ return
}
try {
- sessionStorage.removeItem(storageKey);
+ sessionStorage.removeItem(storageKey)
} catch (error) {
// Fail silently - removal errors should not break the app
if (typeof console !== 'undefined' && console.warn) {
- console.warn('Failed to clear UTM parameters:', error);
+ console.warn('Failed to clear UTM parameters:', error)
}
}
}
@@ -265,8 +260,8 @@ export function clearStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KE
* ```
*/
export function hasStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KEY): boolean {
- const params = getStoredUtmParameters({ storageKey });
- return params !== null && Object.keys(params).length > 0;
+ const params = getStoredUtmParameters({ storageKey })
+ return params !== null && Object.keys(params).length > 0
}
/**
@@ -275,7 +270,7 @@ export function hasStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KEY)
* @returns True if sessionStorage is available and functional
*/
export function isSessionStorageAvailable(): boolean {
- return isStorageAvailable();
+ return isStorageAvailable()
}
/**
@@ -287,12 +282,12 @@ export function isSessionStorageAvailable(): boolean {
*/
export function getRawStoredValue(storageKey: string = DEFAULT_STORAGE_KEY): string | null {
if (!isStorageAvailable()) {
- return null;
+ return null
}
try {
- return sessionStorage.getItem(storageKey);
+ return sessionStorage.getItem(storageKey)
} catch {
- return null;
+ return null
}
}
diff --git a/src/core/validator.ts b/src/core/validator.ts
index 9d24a8e..d7ac684 100644
--- a/src/core/validator.ts
+++ b/src/core/validator.ts
@@ -7,7 +7,7 @@
* - URL normalization (add protocol if missing)
*/
-import type { ValidationResult, ValidationError } from '../types';
+import type { ValidationResult, ValidationError } from '../types'
/**
* Error messages for validation errors
@@ -17,17 +17,17 @@ export const ERROR_MESSAGES: Record = {
invalid_domain: 'URL must have a valid domain with a TLD (e.g., example.com)',
malformed_url: 'URL is malformed or invalid',
empty_url: 'URL cannot be empty',
-};
+}
/**
* Allowed protocols for URL validation
*/
-const ALLOWED_PROTOCOLS = ['http:', 'https:'];
+const ALLOWED_PROTOCOLS = ['http:', 'https:']
/**
* Default protocol to add when normalizing URLs
*/
-let defaultProtocol = 'https://';
+let defaultProtocol = 'https://'
/**
* Validate URL protocol
@@ -38,9 +38,9 @@ function validateProtocol(protocol: string): ValidationResult {
valid: false,
error: 'invalid_protocol',
message: ERROR_MESSAGES.invalid_protocol,
- };
+ }
}
- return { valid: true };
+ return { valid: true }
}
/**
@@ -53,7 +53,7 @@ function validateDomain(hostname: string): ValidationResult {
valid: false,
error: 'invalid_domain',
message: ERROR_MESSAGES.invalid_domain,
- };
+ }
}
// TLD requirement - must contain at least one dot
@@ -62,7 +62,7 @@ function validateDomain(hostname: string): ValidationResult {
valid: false,
error: 'invalid_domain',
message: ERROR_MESSAGES.invalid_domain,
- };
+ }
}
// Ensure hostname is not just dots
@@ -71,17 +71,17 @@ function validateDomain(hostname: string): ValidationResult {
valid: false,
error: 'invalid_domain',
message: ERROR_MESSAGES.invalid_domain,
- };
+ }
}
- return { valid: true };
+ return { valid: true }
}
/**
* Check if a URL string has a protocol
*/
function hasProtocol(url: string): boolean {
- return url.includes('://');
+ return url.includes('://')
}
/**
@@ -114,31 +114,31 @@ export function validateUrl(url: string): ValidationResult {
valid: false,
error: 'empty_url',
message: ERROR_MESSAGES.empty_url,
- };
+ }
}
try {
- const urlObj = new URL(url);
+ const urlObj = new URL(url)
// Protocol validation
- const protocolResult = validateProtocol(urlObj.protocol);
+ const protocolResult = validateProtocol(urlObj.protocol)
if (!protocolResult.valid) {
- return protocolResult;
+ return protocolResult
}
// Domain validation
- const domainResult = validateDomain(urlObj.hostname);
+ const domainResult = validateDomain(urlObj.hostname)
if (!domainResult.valid) {
- return domainResult;
+ return domainResult
}
- return { valid: true };
+ return { valid: true }
} catch {
return {
valid: false,
error: 'malformed_url',
message: ERROR_MESSAGES.malformed_url,
- };
+ }
}
}
@@ -165,18 +165,18 @@ export function validateUrl(url: string): ValidationResult {
*/
export function normalizeUrl(url: string): string {
if (typeof url !== 'string') {
- return url;
+ return url
}
- const trimmedUrl = url.trim();
+ const trimmedUrl = url.trim()
// Check if URL already has a protocol
if (hasProtocol(trimmedUrl)) {
- return trimmedUrl;
+ return trimmedUrl
}
// Add default protocol for URLs without explicit protocol
- return defaultProtocol + trimmedUrl;
+ return defaultProtocol + trimmedUrl
}
/**
@@ -187,9 +187,9 @@ export function normalizeUrl(url: string): string {
*/
export function needsNormalization(url: string): boolean {
if (typeof url !== 'string') {
- return false;
+ return false
}
- return !hasProtocol(url.trim());
+ return !hasProtocol(url.trim())
}
/**
@@ -210,13 +210,13 @@ export function needsNormalization(url: string): boolean {
* ```
*/
export function validateAndNormalize(url: string): ValidationResult & { normalizedUrl?: string } {
- const normalizedUrl = normalizeUrl(url);
- const result = validateUrl(normalizedUrl);
+ const normalizedUrl = normalizeUrl(url)
+ const result = validateUrl(normalizedUrl)
return {
...result,
normalizedUrl,
- };
+ }
}
/**
@@ -225,7 +225,7 @@ export function validateAndNormalize(url: string): ValidationResult & { normaliz
* @returns The default protocol (e.g., 'https://')
*/
export function getDefaultProtocol(): string {
- return defaultProtocol;
+ return defaultProtocol
}
/**
@@ -241,9 +241,9 @@ export function getDefaultProtocol(): string {
*/
export function setDefaultProtocol(protocol: string): void {
if (typeof protocol !== 'string' || !protocol.endsWith('://')) {
- throw new Error('Protocol must be a string ending with "://"');
+ throw new Error('Protocol must be a string ending with "://"')
}
- defaultProtocol = protocol;
+ defaultProtocol = protocol
}
/**
@@ -252,7 +252,7 @@ export function setDefaultProtocol(protocol: string): void {
* @returns Array of allowed protocols (e.g., ['http:', 'https:'])
*/
export function getAllowedProtocols(): string[] {
- return [...ALLOWED_PROTOCOLS];
+ return [...ALLOWED_PROTOCOLS]
}
/**
@@ -262,7 +262,7 @@ export function getAllowedProtocols(): string[] {
* @returns True if protocol is allowed
*/
export function isProtocolAllowed(protocol: string): boolean {
- return ALLOWED_PROTOCOLS.includes(protocol);
+ return ALLOWED_PROTOCOLS.includes(protocol)
}
/**
@@ -272,5 +272,5 @@ export function isProtocolAllowed(protocol: string): boolean {
* @returns Human-readable error message
*/
export function getErrorMessage(error: ValidationError): string {
- return ERROR_MESSAGES[error] || 'Unknown validation error';
+ return ERROR_MESSAGES[error] || 'Unknown validation error'
}
diff --git a/src/debug/index.ts b/src/debug/index.ts
index 274bbc4..d93ceea 100644
--- a/src/debug/index.ts
+++ b/src/debug/index.ts
@@ -6,10 +6,14 @@
* of UTM tracking in their application.
*/
-import type { DiagnosticInfo, ResolvedUtmConfig } from '../types';
-import { captureUtmParameters } from '../core/capture';
-import { getStoredUtmParameters, isSessionStorageAvailable, getRawStoredValue } from '../core/storage';
-import { getDefaultConfig } from '../config/defaults';
+import type { DiagnosticInfo, ResolvedUtmConfig } from '../types'
+import { captureUtmParameters } from '../core/capture'
+import {
+ getStoredUtmParameters,
+ isSessionStorageAvailable,
+ getRawStoredValue,
+} from '../core/storage'
+import { getDefaultConfig } from '../config/defaults'
/**
* Get comprehensive diagnostic information about UTM tracking state
@@ -25,11 +29,11 @@ import { getDefaultConfig } from '../config/defaults';
* ```
*/
export function getDiagnostics(config?: ResolvedUtmConfig): DiagnosticInfo {
- const resolvedConfig = config || getDefaultConfig();
+ const resolvedConfig = config || getDefaultConfig()
// Check if we're in a browser environment
- const isBrowser = typeof window !== 'undefined';
- const currentUrl = isBrowser ? window.location.href : '';
+ const isBrowser = typeof window !== 'undefined'
+ const currentUrl = isBrowser ? window.location.href : ''
// Capture params from current URL
const urlParams = isBrowser
@@ -37,13 +41,13 @@ export function getDiagnostics(config?: ResolvedUtmConfig): DiagnosticInfo {
keyFormat: resolvedConfig.keyFormat,
allowedParameters: resolvedConfig.allowedParameters,
})
- : {};
+ : {}
// Get stored params
const storedParams = getStoredUtmParameters({
storageKey: resolvedConfig.storageKey,
keyFormat: resolvedConfig.keyFormat,
- });
+ })
return {
enabled: resolvedConfig.enabled,
@@ -53,7 +57,7 @@ export function getDiagnostics(config?: ResolvedUtmConfig): DiagnosticInfo {
storedParams,
storageKey: resolvedConfig.storageKey,
storageAvailable: isSessionStorageAvailable(),
- };
+ }
}
/**
@@ -68,41 +72,41 @@ export function getDiagnostics(config?: ResolvedUtmConfig): DiagnosticInfo {
* ```
*/
export function debugUtmState(config?: ResolvedUtmConfig): void {
- const diagnostics = getDiagnostics(config);
+ const diagnostics = getDiagnostics(config)
- console.group('đ UTM Toolkit Debug Info');
- console.log('Enabled:', diagnostics.enabled);
- console.log('Key Format:', diagnostics.config.keyFormat);
- console.log('Storage Key:', diagnostics.storageKey);
- console.log('Storage Available:', diagnostics.storageAvailable);
- console.log('Current URL:', diagnostics.currentUrl);
+ console.group('đ UTM Toolkit Debug Info')
+ console.log('Enabled:', diagnostics.enabled)
+ console.log('Key Format:', diagnostics.config.keyFormat)
+ console.log('Storage Key:', diagnostics.storageKey)
+ console.log('Storage Available:', diagnostics.storageAvailable)
+ console.log('Current URL:', diagnostics.currentUrl)
- console.group('URL Parameters');
+ console.group('URL Parameters')
if (Object.keys(diagnostics.urlParams).length > 0) {
- console.table(diagnostics.urlParams);
+ console.table(diagnostics.urlParams)
} else {
- console.log('(none)');
+ console.log('(none)')
}
- console.groupEnd();
+ console.groupEnd()
- console.group('Stored Parameters');
+ console.group('Stored Parameters')
if (diagnostics.storedParams && Object.keys(diagnostics.storedParams).length > 0) {
- console.table(diagnostics.storedParams);
+ console.table(diagnostics.storedParams)
} else {
- console.log('(none)');
+ console.log('(none)')
}
- console.groupEnd();
-
- console.group('Configuration');
- console.log('Capture On Mount:', diagnostics.config.captureOnMount);
- console.log('Append To Shares:', diagnostics.config.appendToShares);
- console.log('Allowed Parameters:', diagnostics.config.allowedParameters);
- console.log('Exclude From Shares:', diagnostics.config.excludeFromShares);
- console.log('Default Params:', diagnostics.config.defaultParams);
- console.log('Share Context Params:', diagnostics.config.shareContextParams);
- console.groupEnd();
-
- console.groupEnd();
+ console.groupEnd()
+
+ console.group('Configuration')
+ console.log('Capture On Mount:', diagnostics.config.captureOnMount)
+ console.log('Append To Shares:', diagnostics.config.appendToShares)
+ console.log('Allowed Parameters:', diagnostics.config.allowedParameters)
+ console.log('Exclude From Shares:', diagnostics.config.excludeFromShares)
+ console.log('Default Params:', diagnostics.config.defaultParams)
+ console.log('Share Context Params:', diagnostics.config.shareContextParams)
+ console.groupEnd()
+
+ console.groupEnd()
}
/**
@@ -120,43 +124,41 @@ export function debugUtmState(config?: ResolvedUtmConfig): void {
* ```
*/
export function checkUtmTracking(config?: ResolvedUtmConfig): string[] {
- const messages: string[] = [];
- const diagnostics = getDiagnostics(config);
+ const messages: string[] = []
+ const diagnostics = getDiagnostics(config)
if (!diagnostics.enabled) {
- messages.push('â ī¸ UTM tracking is disabled in configuration');
- return messages;
+ messages.push('â ī¸ UTM tracking is disabled in configuration')
+ return messages
}
if (!diagnostics.storageAvailable) {
- messages.push('â ī¸ sessionStorage is not available (private browsing or SSR?)');
+ messages.push('â ī¸ sessionStorage is not available (private browsing or SSR?)')
}
- const urlParamCount = Object.keys(diagnostics.urlParams).length;
+ const urlParamCount = Object.keys(diagnostics.urlParams).length
const storedParamCount = diagnostics.storedParams
? Object.keys(diagnostics.storedParams).length
- : 0;
+ : 0
if (urlParamCount > 0) {
- messages.push(`â
Found ${urlParamCount} UTM parameter(s) in current URL`);
+ messages.push(`â
Found ${urlParamCount} UTM parameter(s) in current URL`)
} else {
- messages.push('âšī¸ No UTM parameters in current URL');
+ messages.push('âšī¸ No UTM parameters in current URL')
}
if (storedParamCount > 0) {
- messages.push(`â
Found ${storedParamCount} UTM parameter(s) in storage`);
+ messages.push(`â
Found ${storedParamCount} UTM parameter(s) in storage`)
} else {
- messages.push('âšī¸ No UTM parameters in storage');
+ messages.push('âšī¸ No UTM parameters in storage')
}
// Check for mismatched params (in URL but not stored)
if (urlParamCount > 0 && storedParamCount === 0 && diagnostics.config.captureOnMount) {
- messages.push(
- 'â ī¸ UTM params in URL but not stored. Hook may not have initialized yet.'
- );
+ messages.push('â ī¸ UTM params in URL but not stored. Hook may not have initialized yet.')
}
- return messages;
+ return messages
}
/**
@@ -181,17 +183,17 @@ export function checkUtmTracking(config?: ResolvedUtmConfig): string[] {
export function installDebugHelpers(config?: ResolvedUtmConfig): void {
// Only install in browser environment
if (typeof window === 'undefined') {
- return;
+ return
}
// Check if debug mode is enabled via URL parameter
// Note: We don't check process.env here as it's not always available in browsers
// Users can enable debug mode by adding ?debug_utm=true to the URL
- const urlParams = new URLSearchParams(window.location.search);
- const debugEnabled = urlParams.get('debug_utm') === 'true';
+ const urlParams = new URLSearchParams(window.location.search)
+ const debugEnabled = urlParams.get('debug_utm') === 'true'
if (!debugEnabled) {
- return;
+ return
}
// Install debug helpers on window
@@ -201,9 +203,9 @@ export function installDebugHelpers(config?: ResolvedUtmConfig): void {
/** Check for issues and log messages */
check: () => {
- const messages = checkUtmTracking(config);
- messages.forEach((msg) => console.log(msg));
- return messages;
+ const messages = checkUtmTracking(config)
+ messages.forEach((msg) => console.log(msg))
+ return messages
},
/** Get diagnostic info as object */
@@ -211,17 +213,17 @@ export function installDebugHelpers(config?: ResolvedUtmConfig): void {
/** Get raw storage value */
raw: (storageKey?: string) => {
- const key = storageKey || config?.storageKey || 'utm_parameters';
- const raw = getRawStoredValue(key);
- console.log(`Raw storage value for "${key}":`, raw);
- return raw;
+ const key = storageKey || config?.storageKey || 'utm_parameters'
+ const raw = getRawStoredValue(key)
+ console.log(`Raw storage value for "${key}":`, raw)
+ return raw
},
- };
+ }
// Attach to window
- (window as unknown as Record).utmDebug = helpers;
+ ;(window as unknown as Record).utmDebug = helpers
console.log(
- 'đ§ UTM debug helpers installed. Use window.utmDebug.state(), .check(), .diagnostics(), or .raw()'
- );
+ 'đ§ UTM debug helpers installed. Use window.utmDebug.state(), .check(), .diagnostics(), or .raw()',
+ )
}
diff --git a/src/index.ts b/src/index.ts
index 6d50d4d..e923096 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -59,7 +59,7 @@ export {
isProtocolAllowed,
getErrorMessage,
ERROR_MESSAGES,
-} from './core';
+} from './core'
// Configuration
export {
@@ -70,15 +70,10 @@ export {
mergeConfig,
loadConfigFromJson,
validateConfig,
-} from './config';
+} from './config'
// Debug utilities
-export {
- getDiagnostics,
- debugUtmState,
- checkUtmTracking,
- installDebugHelpers,
-} from './debug';
+export { getDiagnostics, debugUtmState, checkUtmTracking, installDebugHelpers } from './debug'
// Types
export type {
@@ -99,4 +94,4 @@ export type {
UseUtmTrackingReturn,
UtmProviderProps,
DiagnosticInfo,
-} from './types';
+} from './types'
diff --git a/src/react/UtmProvider.tsx b/src/react/UtmProvider.tsx
index 5b03cb9..10925e2 100644
--- a/src/react/UtmProvider.tsx
+++ b/src/react/UtmProvider.tsx
@@ -6,15 +6,15 @@
* prop drilling.
*/
-import React, { createContext, useContext, useMemo } from 'react';
-import type { UtmConfig, UtmProviderProps, UseUtmTrackingReturn } from '../types';
-import { useUtmTracking } from './useUtmTracking';
+import React, { createContext, useContext, useMemo } from 'react'
+import type { UtmConfig, UtmProviderProps, UseUtmTrackingReturn } from '../types'
+import { useUtmTracking } from './useUtmTracking'
/**
* Context for UTM tracking state
* @internal
*/
-const UtmContext = createContext(null);
+const UtmContext = createContext(null)
/**
* UTM Provider component
@@ -44,23 +44,22 @@ const UtmContext = createContext(null);
*/
export function UtmProvider({ config, children }: UtmProviderProps): React.ReactElement {
// Use the tracking hook with provided config
- const utmTracking = useUtmTracking({ config });
+ const utmTracking = useUtmTracking({ config })
// Memoize the context value to prevent unnecessary re-renders
- const contextValue = useMemo(() => utmTracking, [
- utmTracking.utmParameters,
- utmTracking.isEnabled,
- utmTracking.hasParams,
- utmTracking.capture,
- utmTracking.clear,
- utmTracking.appendToUrl,
- ]);
+ const contextValue = useMemo(
+ () => utmTracking,
+ [
+ utmTracking.utmParameters,
+ utmTracking.isEnabled,
+ utmTracking.hasParams,
+ utmTracking.capture,
+ utmTracking.clear,
+ utmTracking.appendToUrl,
+ ],
+ )
- return (
-
- {children}
-
- );
+ return {children}
}
/**
@@ -82,16 +81,16 @@ export function UtmProvider({ config, children }: UtmProviderProps): React.React
* ```
*/
export function useUtmContext(): UseUtmTrackingReturn {
- const context = useContext(UtmContext);
+ const context = useContext(UtmContext)
if (context === null) {
throw new Error(
'useUtmContext must be used within a UtmProvider. ' +
- 'Wrap your component tree with or use useUtmTracking() directly.'
- );
+ 'Wrap your component tree with or use useUtmTracking() directly.',
+ )
}
- return context;
+ return context
}
/**
@@ -99,7 +98,7 @@ export function useUtmContext(): UseUtmTrackingReturn {
*/
export interface UtmProviderComponentProps {
/** Configuration options */
- config?: Partial;
+ config?: Partial
/** Child components */
- children: React.ReactNode;
+ children: React.ReactNode
}
diff --git a/src/react/index.ts b/src/react/index.ts
index a35dc8c..2cabfde 100644
--- a/src/react/index.ts
+++ b/src/react/index.ts
@@ -4,5 +4,5 @@
* Provides React-specific hooks and components for UTM tracking.
*/
-export { useUtmTracking, type UseUtmTrackingOptions } from './useUtmTracking';
-export { UtmProvider, useUtmContext, type UtmProviderComponentProps } from './UtmProvider';
+export { useUtmTracking, type UseUtmTrackingOptions } from './useUtmTracking'
+export { UtmProvider, useUtmContext, type UtmProviderComponentProps } from './UtmProvider'
diff --git a/src/react/useUtmTracking.ts b/src/react/useUtmTracking.ts
index 63cf2e0..7f35bd3 100644
--- a/src/react/useUtmTracking.ts
+++ b/src/react/useUtmTracking.ts
@@ -5,30 +5,30 @@
* Provides a simple API for UTM tracking throughout React applications.
*/
-import { useState, useCallback, useEffect, useRef } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react'
import type {
UtmConfig,
UtmParameters,
ResolvedUtmConfig,
SharePlatform,
UseUtmTrackingReturn,
-} from '../types';
-import { captureUtmParameters, hasUtmParameters as checkHasParams } from '../core/capture';
+} 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 { createConfig } from '../config/loader';
+} from '../core/storage'
+import { appendUtmParameters } from '../core/appender'
+import { convertParams, isSnakeCaseUtmKey } from '../core/keys'
+import { createConfig } from '../config/loader'
/**
* Options for the useUtmTracking hook
*/
export interface UseUtmTrackingOptions {
/** Configuration options (will be merged with defaults) */
- config?: Partial;
+ config?: Partial
}
/**
@@ -77,20 +77,20 @@ export interface UseUtmTrackingOptions {
*/
export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrackingReturn {
// Create resolved config (merges with defaults)
- const resolvedConfig = useRef(createConfig(options.config));
+ const resolvedConfig = useRef(createConfig(options.config))
// Track if we've initialized
- const hasInitialized = useRef(false);
+ const hasInitialized = useRef(false)
// Get config values for easier access
- const config = resolvedConfig.current;
- const isEnabled = config.enabled;
+ const config = resolvedConfig.current
+ const isEnabled = config.enabled
// State to store current UTM parameters
const [utmParameters, setUtmParameters] = useState(() => {
// SSR safety check
if (typeof window === 'undefined') {
- return null;
+ return null
}
// Initialize from storage if enabled
@@ -98,56 +98,56 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
const stored = getStoredUtmParameters({
storageKey: config.storageKey,
keyFormat: config.keyFormat,
- });
- return stored;
+ })
+ return stored
}
- return null;
- });
+ return null
+ })
/**
* Capture UTM parameters from current URL
*/
const capture = useCallback(() => {
if (!isEnabled) {
- return;
+ return
}
// SSR safety check
if (typeof window === 'undefined') {
- return;
+ return
}
// Capture UTM parameters from current URL
const params = captureUtmParameters(window.location.href, {
keyFormat: config.keyFormat,
allowedParameters: config.allowedParameters,
- });
+ })
// Only store if we found some parameters
if (checkHasParams(params)) {
storeUtmParameters(params, {
storageKey: config.storageKey,
keyFormat: config.keyFormat,
- });
- setUtmParameters(params);
+ })
+ 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);
+ const defaultParams = convertParams(config.defaultParams, config.keyFormat)
storeUtmParameters(defaultParams, {
storageKey: config.storageKey,
keyFormat: config.keyFormat,
- });
- setUtmParameters(defaultParams);
+ })
+ setUtmParameters(defaultParams)
}
- }, [isEnabled, config]);
+ }, [isEnabled, config])
/**
* Clear stored UTM parameters
*/
const clear = useCallback(() => {
- clearStoredUtmParameters(config.storageKey);
- setUtmParameters(null);
- }, [config.storageKey]);
+ clearStoredUtmParameters(config.storageKey)
+ setUtmParameters(null)
+ }, [config.storageKey])
/**
* Append UTM parameters to a URL
@@ -156,69 +156,74 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
(url: string, platform?: SharePlatform): string => {
// If tracking disabled or not configured to append, return URL unchanged
if (!isEnabled || !config.appendToShares) {
- return url;
+ return url
}
// Build merged parameters: captured < default share < platform-specific
- let mergedParams: UtmParameters = {};
+ let mergedParams: UtmParameters = {}
// Start with captured UTMs (if any)
if (utmParameters && checkHasParams(utmParameters)) {
- mergedParams = { ...utmParameters };
+ mergedParams = { ...utmParameters }
}
// Merge share context parameters if configured
if (config.shareContextParams) {
// Apply default share context params first
if (config.shareContextParams.default) {
- mergedParams = { ...mergedParams, ...config.shareContextParams.default };
+ mergedParams = { ...mergedParams, ...config.shareContextParams.default }
}
// Apply platform-specific params (higher priority)
if (platform && config.shareContextParams[platform]) {
- const platformParams = config.shareContextParams[platform];
+ const platformParams = config.shareContextParams[platform]
if (platformParams) {
- mergedParams = { ...mergedParams, ...platformParams };
+ mergedParams = { ...mergedParams, ...platformParams }
}
}
}
// Filter out parameters that should not be shared
if (config.excludeFromShares && config.excludeFromShares.length > 0) {
- const excludeSet = new Set(config.excludeFromShares);
+ const excludeSet = new Set(config.excludeFromShares)
mergedParams = Object.fromEntries(
Object.entries(mergedParams).filter(([key]) => {
// Convert to snake_case for comparison if needed
- const snakeKey = isSnakeCaseUtmKey(key) ? key : `utm_${key.slice(3).replace(/([A-Z])/g, '_$1').toLowerCase()}`;
- return !excludeSet.has(snakeKey) && !excludeSet.has(key);
- })
- ) as UtmParameters;
+ const snakeKey = isSnakeCaseUtmKey(key)
+ ? key
+ : `utm_${key
+ .slice(3)
+ .replace(/([A-Z])/g, '_$1')
+ .toLowerCase()}`
+ return !excludeSet.has(snakeKey) && !excludeSet.has(key)
+ }),
+ ) as UtmParameters
}
// If no parameters to append, return URL unchanged
if (!checkHasParams(mergedParams)) {
- return url;
+ return url
}
- return appendUtmParameters(url, mergedParams);
+ return appendUtmParameters(url, mergedParams)
},
- [isEnabled, config, utmParameters]
- );
+ [isEnabled, config, utmParameters],
+ )
// Auto-capture on mount if configured
useEffect(() => {
if (hasInitialized.current) {
- return;
+ return
}
- hasInitialized.current = true;
+ hasInitialized.current = true
if (isEnabled && config.captureOnMount) {
- capture();
+ capture()
}
- }, [isEnabled, config.captureOnMount, capture]);
+ }, [isEnabled, config.captureOnMount, capture])
// Compute hasParams
- const hasParams = checkHasParams(utmParameters);
+ const hasParams = checkHasParams(utmParameters)
return {
utmParameters,
@@ -227,5 +232,5 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack
capture,
clear,
appendToUrl,
- };
+ }
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 4efc343..1873388 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -3,7 +3,7 @@
* - 'snake_case': URL-style format (utm_source, utm_campaign)
* - 'camelCase': TypeScript-style format (utmSource, utmCampaign)
*/
-export type KeyFormat = 'snake_case' | 'camelCase';
+export type KeyFormat = 'snake_case' | 'camelCase'
/**
* Standard UTM parameter keys in snake_case (URL format)
@@ -14,12 +14,12 @@ export type StandardSnakeCaseUtmKey =
| 'utm_campaign'
| 'utm_term'
| 'utm_content'
- | 'utm_id';
+ | 'utm_id'
/**
* Any UTM key in snake_case format (includes custom params like utm_team_id)
*/
-export type SnakeCaseUtmKey = StandardSnakeCaseUtmKey | `utm_${string}`;
+export type SnakeCaseUtmKey = StandardSnakeCaseUtmKey | `utm_${string}`
/**
* Standard UTM parameter keys in camelCase (TypeScript format)
@@ -30,43 +30,43 @@ export type StandardCamelCaseUtmKey =
| 'utmCampaign'
| 'utmTerm'
| 'utmContent'
- | 'utmId';
+ | 'utmId'
/**
* UTM parameters object using snake_case keys (URL format)
*/
export interface UtmParametersSnake {
- utm_source?: string;
- utm_medium?: string;
- utm_campaign?: string;
- utm_term?: string;
- utm_content?: string;
- utm_id?: string;
- [key: `utm_${string}`]: string | undefined;
+ utm_source?: string
+ utm_medium?: string
+ utm_campaign?: string
+ utm_term?: string
+ utm_content?: string
+ utm_id?: string
+ [key: `utm_${string}`]: string | undefined
}
/**
* UTM parameters object using camelCase keys (TypeScript format)
*/
export interface UtmParametersCamel {
- utmSource?: string;
- utmMedium?: string;
- utmCampaign?: string;
- utmTerm?: string;
- utmContent?: string;
- utmId?: string;
- [key: string]: string | undefined;
+ utmSource?: string
+ utmMedium?: string
+ utmCampaign?: string
+ utmTerm?: string
+ utmContent?: string
+ utmId?: string
+ [key: string]: string | undefined
}
/**
* Union type for UTM parameters - can be either format
*/
-export type UtmParameters = UtmParametersSnake | UtmParametersCamel;
+export type UtmParameters = UtmParametersSnake | UtmParametersCamel
/**
* Platform identifiers for share context configuration
*/
-export type SharePlatform = 'linkedin' | 'twitter' | 'facebook' | 'copy' | string;
+export type SharePlatform = 'linkedin' | 'twitter' | 'facebook' | 'copy' | string
/**
* Platform-specific UTM parameter overrides for sharing
@@ -74,17 +74,17 @@ export type SharePlatform = 'linkedin' | 'twitter' | 'facebook' | 'copy' | strin
* - Platform keys (linkedin, twitter, etc.): Override specific parameters per platform
*/
export type ShareContextParams = Partial> & {
- default?: UtmParameters;
-};
+ default?: UtmParameters
+}
/**
* Options for appending UTM parameters to URLs
*/
export interface AppendOptions {
/** Add parameters to URL fragment (#) instead of query string (?) */
- toFragment?: boolean;
+ toFragment?: boolean
/** Keep existing UTM parameters instead of replacing them */
- preserveExisting?: boolean;
+ preserveExisting?: boolean
}
/**
@@ -92,70 +92,66 @@ export interface AppendOptions {
*/
export interface ValidationResult {
/** Whether the URL is valid */
- valid: boolean;
+ valid: boolean
/** Error identifier if invalid */
- error?: ValidationError;
+ error?: ValidationError
/** Human-readable error message */
- message?: string;
+ message?: string
}
/**
* Validation error types
*/
-export type ValidationError =
- | 'invalid_protocol'
- | 'invalid_domain'
- | 'malformed_url'
- | 'empty_url';
+export type ValidationError = 'invalid_protocol' | 'invalid_domain' | 'malformed_url' | 'empty_url'
/**
* Main configuration interface for UTM toolkit
*/
export interface UtmConfig {
/** Enable/disable UTM tracking entirely (default: true) */
- enabled?: boolean;
+ enabled?: boolean
/** Key format for returned UTM parameters (default: 'snake_case') */
- keyFormat?: KeyFormat;
+ keyFormat?: KeyFormat
/** Storage key prefix for sessionStorage (default: 'utm_parameters') */
- storageKey?: string;
+ storageKey?: string
/** Auto-capture UTM params on React hook mount (default: true) */
- captureOnMount?: boolean;
+ captureOnMount?: boolean
/** Auto-append UTM params when generating share URLs (default: true) */
- appendToShares?: boolean;
+ appendToShares?: boolean
/**
* Allowlist of UTM parameters to capture (snake_case format)
* Default: ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id']
*/
- allowedParameters?: string[];
+ allowedParameters?: string[]
/** Default UTM parameters when none are captured */
- defaultParams?: UtmParameters;
+ defaultParams?: UtmParameters
/** Platform-specific UTM overrides for share URLs */
- shareContextParams?: ShareContextParams;
+ shareContextParams?: ShareContextParams
/** Parameters to exclude when appending to share URLs (e.g., ['utm_team_id']) */
- excludeFromShares?: string[];
+ excludeFromShares?: string[]
}
/**
* Fully resolved configuration with all defaults applied
*/
export interface ResolvedUtmConfig {
- enabled: boolean;
- keyFormat: KeyFormat;
- storageKey: string;
- captureOnMount: boolean;
- appendToShares: boolean;
- allowedParameters: string[];
- defaultParams: UtmParameters;
- shareContextParams: ShareContextParams;
- excludeFromShares: string[];
+ enabled: boolean
+ keyFormat: KeyFormat
+ storageKey: string
+ captureOnMount: boolean
+ appendToShares: boolean
+ allowedParameters: string[]
+ defaultParams: UtmParameters
+ shareContextParams: ShareContextParams
+ excludeFromShares: string[]
}
/**
@@ -163,19 +159,19 @@ export interface ResolvedUtmConfig {
*/
export interface UseUtmTrackingReturn {
/** Currently captured/stored UTM parameters */
- utmParameters: UtmParameters | null;
+ utmParameters: UtmParameters | null
/** Whether UTM tracking is enabled */
- isEnabled: boolean;
+ isEnabled: boolean
/** Whether any UTM parameters exist */
- hasParams: boolean;
+ hasParams: boolean
/** Manually capture UTM parameters from current URL */
- capture: () => void;
+ capture: () => void
/** Clear stored UTM parameters */
- clear: () => void;
+ clear: () => void
/**
* Append UTM parameters to a URL
@@ -183,7 +179,7 @@ export interface UseUtmTrackingReturn {
* @param platform - Optional platform for context-specific params
* @returns URL with UTM parameters appended
*/
- appendToUrl: (url: string, platform?: SharePlatform) => string;
+ appendToUrl: (url: string, platform?: SharePlatform) => string
}
/**
@@ -191,10 +187,10 @@ export interface UseUtmTrackingReturn {
*/
export interface UtmProviderProps {
/** Configuration options */
- config?: Partial;
+ config?: Partial
/** Child components */
- children: React.ReactNode;
+ children: React.ReactNode
}
/**
@@ -202,23 +198,23 @@ export interface UtmProviderProps {
*/
export interface DiagnosticInfo {
/** Whether tracking is enabled */
- enabled: boolean;
+ enabled: boolean
/** Current configuration */
- config: ResolvedUtmConfig;
+ config: ResolvedUtmConfig
/** Current URL */
- currentUrl: string;
+ currentUrl: string
/** UTM parameters found in current URL */
- urlParams: UtmParameters;
+ urlParams: UtmParameters
/** UTM parameters in storage */
- storedParams: UtmParameters | null;
+ storedParams: UtmParameters | null
/** Storage key being used */
- storageKey: string;
+ storageKey: string
/** Whether sessionStorage is available */
- storageAvailable: boolean;
+ storageAvailable: boolean
}