diff --git a/src/lib/util/redirect.test.ts b/src/lib/util/redirect.test.ts new file mode 100644 index 00000000..fd465593 --- /dev/null +++ b/src/lib/util/redirect.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { buildRedirectUrl } from './redirect'; + +const mockLocation = (url: string): Location => { + const parsed = new URL(url); + return { hash: parsed.hash, search: parsed.search } as Location; +}; + +describe('buildRedirectUrl', () => { + it('should redirect to /edit by default when hash is empty', () => { + expect(buildRedirectUrl(mockLocation('https://mermaid.live/'))).toBe('/edit'); + }); + + it('should preserve search params', () => { + expect(buildRedirectUrl(mockLocation('https://mermaid.live/?utm_source=github'))).toBe( + '/edit?utm_source=github' + ); + }); + + it('should extract route and fragment from old hash format', () => { + expect(buildRedirectUrl(mockLocation('https://mermaid.live/#/edit/pako:abc123'))).toBe( + '/edit#pako:abc123' + ); + }); + + it('should place search params before the hash fragment', () => { + expect( + buildRedirectUrl(mockLocation('https://mermaid.live/?utm_source=twitter#/edit/pako:abc123')) + ).toBe('/edit?utm_source=twitter#pako:abc123'); + }); + + it('should handle hash with view route', () => { + expect(buildRedirectUrl(mockLocation('https://mermaid.live/#/view/pako:xyz'))).toBe( + '/view#pako:xyz' + ); + }); + + it('should default to edit when hash has no route', () => { + expect(buildRedirectUrl(mockLocation('https://mermaid.live/#somethingelse'))).toBe('/edit'); + }); + + it('should handle multiple search params with hash', () => { + expect( + buildRedirectUrl( + mockLocation( + 'https://mermaid.live/?utm_source=gh&utm_medium=link&utm_campaign=test#/edit/pako:data' + ) + ) + ).toBe('/edit?utm_source=gh&utm_medium=link&utm_campaign=test#pako:data'); + }); +}); diff --git a/src/lib/util/redirect.ts b/src/lib/util/redirect.ts new file mode 100644 index 00000000..26a1d923 --- /dev/null +++ b/src/lib/util/redirect.ts @@ -0,0 +1,15 @@ +/** + * Build the redirect URL for legacy root-path links. + * Extracts the route and fragment from the old hash-based URL format, + * and ensures search params (e.g. UTM) come before the hash fragment. + */ +export const buildRedirectUrl = (location: Location): string => { + const parts = location.hash.split('/'); + let path = 'edit'; + let fragment = ''; + if (parts.length > 2) { + path = parts[1]; + fragment = `#${parts[2]}`; + } + return `/${path}${location.search}${fragment}`; +}; diff --git a/src/lib/util/stats.test.ts b/src/lib/util/stats.test.ts new file mode 100644 index 00000000..da868d3e --- /dev/null +++ b/src/lib/util/stats.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { getAnalyticsSafeUrl } from './stats'; + +describe('getAnalyticsUrl', () => { + beforeEach(() => { + // Reset location to a clean state before each test + window.history.replaceState(null, '', '/'); + }); + + it('should return origin and pathname for a simple URL', () => { + window.history.replaceState(null, '', '/edit'); + const url = getAnalyticsSafeUrl(); + expect(url).toBe(`${window.location.origin}/edit`); + }); + + it('should include search/query params (UTM parameters)', () => { + window.history.replaceState(null, '', '/edit?utm_source=github&utm_medium=docs'); + const url = getAnalyticsSafeUrl(); + expect(url).toBe(`${window.location.origin}/edit?utm_source=github&utm_medium=docs`); + }); + + it('should never include the hash', () => { + window.history.replaceState(null, '', '/edit#pako:someDiagramData'); + // replaceState doesn't set hash, so set it via location + window.location.hash = '#pako:someDiagramData'; + const url = getAnalyticsSafeUrl(); + expect(url).not.toContain('#'); + expect(url).not.toContain('pako:'); + expect(url).toBe(`${window.location.origin}/edit`); + }); + + it('should include search params but exclude hash when both are present', () => { + window.history.replaceState(null, '', '/edit?utm_campaign=launch'); + window.location.hash = '#pako:diagramDataHere'; + const url = getAnalyticsSafeUrl(); + expect(url).not.toContain('#'); + expect(url).not.toContain('pako:'); + expect(url).toContain('utm_campaign=launch'); + expect(url).toBe(`${window.location.origin}/edit?utm_campaign=launch`); + }); + + it('should return just origin for root path with no params', () => { + window.history.replaceState(null, '', '/'); + const url = getAnalyticsSafeUrl(); + expect(url).toBe(`${window.location.origin}/`); + }); +}); diff --git a/src/lib/util/stats.ts b/src/lib/util/stats.ts index 7bc0eaea..1aab01e6 100644 --- a/src/lib/util/stats.ts +++ b/src/lib/util/stats.ts @@ -23,6 +23,15 @@ export const initAnalytics = async (): Promise => { } }; +/** + * Build the current page URL for analytics tracking. + * Includes origin, pathname, and search (for UTM params), + * but never the hash (which contains diagram data). + */ +export const getAnalyticsSafeUrl = (): string => { + return window.location.origin + window.location.pathname + window.location.search; +}; + export const countLines = (code: string): number => { return (code.match(/\n/g)?.length ?? 0) + 1; }; @@ -111,11 +120,7 @@ export const logEvent = ( if (timeouts.has(key)) { clearTimeout(timeouts.get(key)); } else { - plausible.trackEvent( - name, - { props: data }, - { url: window.location.origin + window.location.pathname } - ); + plausible.trackEvent(name, { props: data }, { url: getAnalyticsSafeUrl() }); } timeouts.set( key, diff --git a/src/lib/util/util.ts b/src/lib/util/util.ts index c252f370..2b272be1 100644 --- a/src/lib/util/util.ts +++ b/src/lib/util/util.ts @@ -5,7 +5,7 @@ import { initLoading } from './loading'; import { isOnMermaidAI } from './migration/domainMigration'; import { applyMigrations } from './migrations'; import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state'; -import { initAnalytics, plausible } from './stats'; +import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats'; export const getDomain = (url?: string): string => { if (!url) return ''; @@ -30,7 +30,9 @@ export const initHandler = async (): Promise => { syncDiagram(); initURLSubscription(); await initAnalytics(); - plausible?.trackPageview({ url: window.location.origin + window.location.pathname }); + plausible?.trackPageview({ + url: getAnalyticsSafeUrl() + }); verifyState(); }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4a12a8b8..ea21f3ca 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,16 +1,10 @@