Skip to content

Commit abe57c4

Browse files
harlan-zwclaude
andauthored
fix: merge query parms when overriding scriptInput.src (#500)
Co-authored-by: Claude <[email protected]>
1 parent 4854b4c commit abe57c4

File tree

4 files changed

+204
-16
lines changed

4 files changed

+204
-16
lines changed

docs/content/scripts/tracking/google-tag-manager.md

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
title: Google Tag Manager
33
description: Use Google Tag Manager in your Nuxt app.
44
links:
5-
- label: Source
6-
icon: i-simple-icons-github
7-
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/google-tag-manager.ts
8-
size: xs
5+
- label: Source
6+
icon: i-simple-icons-github
7+
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/google-tag-manager.ts
8+
size: xs
99
---
1010

1111
[Google Tag Manager](https://marketingplatform.google.com/about/tag-manager/) is a tag management system that allows you to quickly and easily update tags and code snippets on your website or mobile app, such as those intended for traffic analysis and marketing optimization.
@@ -62,7 +62,7 @@ export default defineNuxtConfig({
6262
googleTagManager: {
6363
// .env
6464
// NUXT_PUBLIC_SCRIPTS_GOOGLE_TAG_MANAGER_ID=<your-id>
65-
id: '',
65+
id: '',
6666
},
6767
},
6868
},
@@ -95,14 +95,14 @@ This composable will trigger the provided function on route change after the pag
9595
```ts
9696
const { proxy } = useScriptGoogleTagManager({
9797
id: 'YOUR_ID' // id is only needed if you haven't configured globally
98-
})
98+
})
9999

100100
useScriptEventPage((title, path) => {
101101
// triggered on route change after title is updated
102-
proxy.dataLayer.push({
102+
proxy.dataLayer.push({
103103
event: 'pageview',
104-
title,
105-
path
104+
title,
105+
path
106106
})
107107
})
108108
```
@@ -163,7 +163,41 @@ export const GoogleTagManagerOptions = object({
163163
type GoogleTagManagerInput = typeof GoogleTagManagerOptions & { onBeforeGtmStart?: (gtag: Gtag) => void }
164164
```
165165
166-
## Example
166+
## Examples
167+
168+
### Server-Side GTM Setup
169+
170+
We can add custom GTM script source for server-side implementation. You can override the script src, this will merge in any of the computed query params.
171+
172+
```ts
173+
// nuxt.config.ts
174+
export default defineNuxtConfig({
175+
scripts: {
176+
registry: {
177+
googleTagManager: {
178+
id: 'GTM-XXXXXX',
179+
scriptInput: {
180+
src: 'https://your-domain.com/gtm.js'
181+
}
182+
}
183+
}
184+
}
185+
})
186+
```
187+
188+
```vue
189+
<!-- Component usage -->
190+
<script setup lang="ts">
191+
const { proxy } = useScriptGoogleTagManager({
192+
id: 'GTM-XXXXXX',
193+
scriptInput: {
194+
src: 'https://your-domain.com/gtm.js'
195+
}
196+
})
197+
</script>
198+
```
199+
200+
### Basic Usage
167201

168202
Using Google Tag Manager only in production while using `dataLayer` to send a conversion event.
169203

src/runtime/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
} from '@unhead/vue/types'
44
import type { UseScriptInput, VueScriptInstance, UseScriptOptions } from '@unhead/vue'
55
import type { ComputedRef, Ref } from 'vue'
6-
import type {InferInput, ObjectSchema, ValiError} from 'valibot'
6+
import type { InferInput, ObjectSchema, ValiError } from 'valibot'
77
import type { Import } from 'unimport'
88
import type { SegmentInput } from './registry/segment'
99
import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics'
@@ -87,7 +87,7 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}> =
8787
registryMeta?: Record<string, string>
8888
}
8989
/**
90-
* @internal Used to run custom validation logic in dev mode.
90+
* @internal
9191
*/
9292
_validate?: () => ValiError<any> | null | undefined
9393
}

src/runtime/utils.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
UseFunctionType,
1313
ScriptRegistry, UseScriptContext,
1414
} from '#nuxt-scripts/types'
15+
import { parseQuery, parseURL, withQuery } from 'ufo'
1516

1617
export type MaybePromise<T> = Promise<T> | T
1718

@@ -22,14 +23,14 @@ function validateScriptInputSchema<T extends GenericSchema>(key: string, schema:
2223
}
2324
catch (_e) {
2425
const e = _e as ValiError<any>
25-
console.error(e.issues.map(i => `${key}.${i.path?.map(i => i.key).join(',')}: ${i.message}`).join('\n'))
26+
console.error(e.issues.map((i: any) => `${key}.${i.path?.map((i: any) => i.key).join(',')}: ${i.message}`).join('\n'))
2627
return e
2728
}
2829
}
2930
return null
3031
}
3132

32-
type OptionsFn<O> = (options: InferIfSchema<O>) => ({
33+
type OptionsFn<O> = (options: InferIfSchema<O>, ctx: { scriptInput?: UseScriptInput & { src?: string } }) => ({
3334
scriptInput?: UseScriptInput
3435
scriptOptions?: NuxtUseScriptOptions
3536
schema?: O extends ObjectSchema<any, any> ? O : undefined
@@ -43,9 +44,34 @@ export function scriptRuntimeConfig<T extends keyof ScriptRegistry>(key: T) {
4344
export function useRegistryScript<T extends Record<string | symbol, any>, O = EmptyOptionsSchema>(registryKey: keyof ScriptRegistry | string, optionsFn: OptionsFn<O>, _userOptions?: RegistryScriptInput<O>): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> {
4445
const scriptConfig = scriptRuntimeConfig(registryKey as keyof ScriptRegistry)
4546
const userOptions = Object.assign(_userOptions || {}, typeof scriptConfig === 'object' ? scriptConfig : {})
46-
const options = optionsFn(userOptions as InferIfSchema<O>)
47+
const options = optionsFn(userOptions as InferIfSchema<O>, { scriptInput: userOptions.scriptInput as UseScriptInput & { src?: string } })
4748

48-
const scriptInput = defu(userOptions.scriptInput, options.scriptInput, { key: registryKey }) as any as UseScriptInput
49+
let finalScriptInput = options.scriptInput
50+
51+
// If user provided a custom src and the options function returned a src with query params,
52+
// extract those query params and apply them to the user's custom src
53+
const userSrc = (userOptions.scriptInput as any)?.src
54+
const optionsSrc = (options.scriptInput as any)?.src
55+
56+
if (userSrc && optionsSrc && typeof optionsSrc === 'string' && typeof userSrc === 'string') {
57+
const defaultUrl = parseURL(optionsSrc)
58+
const customUrl = parseURL(userSrc)
59+
60+
// Merge query params: user params override default params
61+
const defaultQuery = parseQuery(defaultUrl.search || '')
62+
const customQuery = parseQuery(customUrl.search || '')
63+
const mergedQuery = { ...defaultQuery, ...customQuery }
64+
65+
// Build the final URL with the custom base and merged query params
66+
const baseUrl = customUrl.href?.split('?')[0] || userSrc
67+
68+
finalScriptInput = {
69+
...((options.scriptInput as object) || {}),
70+
src: withQuery(baseUrl, mergedQuery),
71+
}
72+
}
73+
74+
const scriptInput = defu(finalScriptInput, userOptions.scriptInput, { key: registryKey }) as any as UseScriptInput
4975
const scriptOptions = Object.assign(userOptions?.scriptOptions || {}, options.scriptOptions || {})
5076
if (import.meta.dev) {
5177
scriptOptions.devtools = defu(scriptOptions.devtools, { registryKey })

test/unit/utils.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { useRegistryScript } from '../../src/runtime/utils'
3+
4+
// Mock dependencies
5+
vi.mock('nuxt/app', () => ({
6+
useRuntimeConfig: () => ({ public: { scripts: {} } }),
7+
}))
8+
9+
vi.mock('../../src/runtime/composables/useScript', () => ({
10+
useScript: vi.fn((input, options) => ({ input, options })),
11+
}))
12+
13+
vi.mock('#nuxt-scripts-validator', () => ({
14+
parse: vi.fn(),
15+
}))
16+
17+
describe('useRegistryScript query param merging', () => {
18+
it('should merge query params when user provides custom src', () => {
19+
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
20+
scriptInput: {
21+
src: 'https://example.com/script.js?id=123&auth=abc&existing=default',
22+
},
23+
}))
24+
25+
const userOptions = {
26+
scriptInput: {
27+
src: 'https://custom-domain.com/script.js?auth=override&new=param',
28+
},
29+
}
30+
31+
const result = useRegistryScript('test', mockOptionsFunction, userOptions)
32+
33+
// The options function should be called with the user options and context
34+
expect(mockOptionsFunction).toHaveBeenCalledWith(
35+
userOptions,
36+
{ scriptInput: userOptions.scriptInput },
37+
)
38+
39+
// Check the result contains merged query params (user params come first due to object spread)
40+
expect(result.input.src).toBe('https://custom-domain.com/script.js?auth=override&new=param&id=123&existing=default')
41+
})
42+
43+
it('should preserve user query params over default ones', () => {
44+
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
45+
scriptInput: {
46+
src: 'https://example.com/script.js?param1=default&param2=default',
47+
},
48+
}))
49+
50+
const userOptions = {
51+
scriptInput: {
52+
src: 'https://custom-domain.com/script.js?param1=override',
53+
},
54+
}
55+
56+
const result = useRegistryScript('test', mockOptionsFunction, userOptions)
57+
58+
expect(result.input.src).toBe('https://custom-domain.com/script.js?param1=override&param2=default')
59+
})
60+
61+
it('should handle cases where user src has no query params', () => {
62+
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
63+
scriptInput: {
64+
src: 'https://example.com/script.js?id=123&auth=abc',
65+
},
66+
}))
67+
68+
const userOptions = {
69+
scriptInput: {
70+
src: 'https://custom-domain.com/script.js',
71+
},
72+
}
73+
74+
const result = useRegistryScript('test', mockOptionsFunction, userOptions)
75+
76+
expect(result.input.src).toBe('https://custom-domain.com/script.js?id=123&auth=abc')
77+
})
78+
79+
it('should handle cases where default src has no query params', () => {
80+
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
81+
scriptInput: {
82+
src: 'https://example.com/script.js',
83+
},
84+
}))
85+
86+
const userOptions = {
87+
scriptInput: {
88+
src: 'https://custom-domain.com/script.js?custom=param',
89+
},
90+
}
91+
92+
const result = useRegistryScript('test', mockOptionsFunction, userOptions)
93+
94+
expect(result.input.src).toBe('https://custom-domain.com/script.js?custom=param')
95+
})
96+
97+
it('should not modify src when no user src is provided', () => {
98+
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
99+
scriptInput: {
100+
src: 'https://example.com/script.js?id=123&auth=abc',
101+
},
102+
}))
103+
104+
const userOptions = {}
105+
106+
const result = useRegistryScript('test', mockOptionsFunction, userOptions)
107+
108+
expect(result.input.src).toBe('https://example.com/script.js?id=123&auth=abc')
109+
})
110+
111+
it('should handle complex URLs with paths and fragments', () => {
112+
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
113+
scriptInput: {
114+
src: 'https://example.com/path/to/script.js?id=123&version=1',
115+
},
116+
}))
117+
118+
const userOptions = {
119+
scriptInput: {
120+
src: 'https://custom-domain.com/custom/path/script.js?version=2&custom=true',
121+
},
122+
}
123+
124+
const result = useRegistryScript('test', mockOptionsFunction, userOptions)
125+
126+
expect(result.input.src).toBe('https://custom-domain.com/custom/path/script.js?version=2&custom=true&id=123')
127+
})
128+
})

0 commit comments

Comments
 (0)