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 }