Skip to content

Commit 84dbe77

Browse files
committed
Merge branches 'main' and 'main' of github.com:nuxt/scripts
2 parents 1ae8043 + 1edcd3d commit 84dbe77

File tree

4 files changed

+240
-16
lines changed

4 files changed

+240
-16
lines changed

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

Lines changed: 78 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

@@ -198,3 +232,37 @@ function sendConversion() {
198232
`useScriptGoogleTagManager` initialize Google Tag Manager by itself. This means it pushes the `js`, `config` and the `gtm.start` events for you.
199233

200234
If you need to configure GTM before it starts. For example, [setting the consent mode](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). You can use the `onBeforeGtmStart` hook which is run right before we push the `gtm.start` event into the dataLayer.
235+
236+
```vue
237+
const { proxy } = useScriptGoogleTagManager({
238+
onBeforeGtmStart: (gtag) => {
239+
// set default consent state to denied
240+
gtag('consent', 'default', {
241+
'ad_user_data': 'denied',
242+
'ad_personalization': 'denied',
243+
'ad_storage': 'denied',
244+
'analytics_storage': 'denied',
245+
'wait_for_update': 500,
246+
})
247+
248+
// if consent was already given, update gtag accordingly
249+
if (consent.value === 'granted') {
250+
gtag('consent', 'update', {
251+
ad_user_data: consent.value,
252+
ad_personalization: consent.value,
253+
ad_storage: consent.value,
254+
analytics_storage: consent.value
255+
})
256+
}
257+
}
258+
})
259+
260+
// push pageview events to dataLayer
261+
useScriptEventPage(({ title, path }) => {
262+
proxy.dataLayer.push({
263+
event: 'pageview',
264+
title,
265+
path
266+
})
267+
})
268+
```

src/runtime/registry/google-tag-manager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,14 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
159159
clientInit: import.meta.server
160160
? undefined
161161
: () => {
162-
// Initialize dataLayer if it doesn't exist
162+
// Initialize dataLayer if it doesn't exist
163163
(window as any)[dataLayerName] = (window as any)[dataLayerName] || []
164164

165165
// Create gtag function
166-
function gtag(...args: any[]) {
167-
(window as any)[dataLayerName].push(args)
166+
function gtag() {
167+
// Pushing arguments to dataLayer is necessary for GTM to process events
168+
// eslint-disable-next-line prefer-rest-params
169+
(window as any)[dataLayerName].push(arguments)
168170
}
169171

170172
// Allow custom initialization

src/runtime/utils.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
UseFunctionType,
1414
ScriptRegistry, UseScriptContext,
1515
} from '#nuxt-scripts/types'
16+
import { parseQuery, parseURL, withQuery } from 'ufo'
1617

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

@@ -30,7 +31,7 @@ function validateScriptInputSchema<T extends GenericSchema>(key: string, schema:
3031
return null
3132
}
3233

33-
type OptionsFn<O> = (options: InferIfSchema<O>) => ({
34+
type OptionsFn<O> = (options: InferIfSchema<O>, ctx: { scriptInput?: UseScriptInput & { src?: string } }) => ({
3435
scriptInput?: UseScriptInput
3536
scriptOptions?: NuxtUseScriptOptions
3637
schema?: O extends ObjectSchema<any, any> ? O : undefined
@@ -44,9 +45,34 @@ export function scriptRuntimeConfig<T extends keyof ScriptRegistry>(key: T) {
4445
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>> {
4546
const scriptConfig = scriptRuntimeConfig(registryKey as keyof ScriptRegistry)
4647
const userOptions = Object.assign(_userOptions || {}, typeof scriptConfig === 'object' ? scriptConfig : {})
47-
const options = optionsFn(userOptions as InferIfSchema<O>)
48+
const options = optionsFn(userOptions as InferIfSchema<O>, { scriptInput: userOptions.scriptInput as UseScriptInput & { src?: string } })
4849

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