Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions docs/content/scripts/tracking/google-tag-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
title: Google Tag Manager
description: Use Google Tag Manager in your Nuxt app.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/google-tag-manager.ts
size: xs
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/google-tag-manager.ts
size: xs
---

[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.
Expand Down Expand Up @@ -62,7 +62,7 @@ export default defineNuxtConfig({
googleTagManager: {
// .env
// NUXT_PUBLIC_SCRIPTS_GOOGLE_TAG_MANAGER_ID=<your-id>
id: '',
id: '',
},
},
},
Expand Down Expand Up @@ -95,14 +95,14 @@ This composable will trigger the provided function on route change after the pag
```ts
const { proxy } = useScriptGoogleTagManager({
id: 'YOUR_ID' // id is only needed if you haven't configured globally
})
})

useScriptEventPage((title, path) => {
// triggered on route change after title is updated
proxy.dataLayer.push({
proxy.dataLayer.push({
event: 'pageview',
title,
path
title,
path
})
})
```
Expand Down Expand Up @@ -163,7 +163,41 @@ export const GoogleTagManagerOptions = object({
type GoogleTagManagerInput = typeof GoogleTagManagerOptions & { onBeforeGtmStart?: (gtag: Gtag) => void }
```

## Example
## Examples

### Server-Side GTM Setup

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.

```ts
// nuxt.config.ts
export default defineNuxtConfig({
scripts: {
registry: {
googleTagManager: {
id: 'GTM-XXXXXX',
scriptInput: {
src: 'https://your-domain.com/gtm.js'
}
}
}
}
})
```

```vue
<!-- Component usage -->
<script setup lang="ts">
const { proxy } = useScriptGoogleTagManager({
id: 'GTM-XXXXXX',
scriptInput: {
src: 'https://your-domain.com/gtm.js'
}
})
</script>
```

### Basic Usage

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

Expand Down
4 changes: 2 additions & 2 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
} from '@unhead/vue/types'
import type { UseScriptInput, VueScriptInstance, UseScriptOptions } from '@unhead/vue'
import type { ComputedRef, Ref } from 'vue'
import type {InferInput, ObjectSchema, ValiError} from 'valibot'
import type { InferInput, ObjectSchema, ValiError } from 'valibot'
import type { Import } from 'unimport'
import type { SegmentInput } from './registry/segment'
import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics'
Expand Down Expand Up @@ -87,7 +87,7 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}> =
registryMeta?: Record<string, string>
}
/**
* @internal Used to run custom validation logic in dev mode.
* @internal
*/
_validate?: () => ValiError<any> | null | undefined
}
Expand Down
34 changes: 30 additions & 4 deletions src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
UseFunctionType,
ScriptRegistry, UseScriptContext,
} from '#nuxt-scripts/types'
import { parseQuery, parseURL, withQuery } from 'ufo'

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

Expand All @@ -22,14 +23,14 @@ function validateScriptInputSchema<T extends GenericSchema>(key: string, schema:
}
catch (_e) {
const e = _e as ValiError<any>
console.error(e.issues.map(i => `${key}.${i.path?.map(i => i.key).join(',')}: ${i.message}`).join('\n'))
console.error(e.issues.map((i: any) => `${key}.${i.path?.map((i: any) => i.key).join(',')}: ${i.message}`).join('\n'))
return e
}
}
return null
}

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

const scriptInput = defu(userOptions.scriptInput, options.scriptInput, { key: registryKey }) as any as UseScriptInput
let finalScriptInput = options.scriptInput

// If user provided a custom src and the options function returned a src with query params,
// extract those query params and apply them to the user's custom src
const userSrc = (userOptions.scriptInput as any)?.src
const optionsSrc = (options.scriptInput as any)?.src

if (userSrc && optionsSrc && typeof optionsSrc === 'string' && typeof userSrc === 'string') {
const defaultUrl = parseURL(optionsSrc)
const customUrl = parseURL(userSrc)

// Merge query params: user params override default params
const defaultQuery = parseQuery(defaultUrl.search || '')
const customQuery = parseQuery(customUrl.search || '')
const mergedQuery = { ...defaultQuery, ...customQuery }

// Build the final URL with the custom base and merged query params
const baseUrl = customUrl.href?.split('?')[0] || userSrc

finalScriptInput = {
...((options.scriptInput as object) || {}),
src: withQuery(baseUrl, mergedQuery),
}
}

const scriptInput = defu(finalScriptInput, userOptions.scriptInput, { key: registryKey }) as any as UseScriptInput
const scriptOptions = Object.assign(userOptions?.scriptOptions || {}, options.scriptOptions || {})
if (import.meta.dev) {
scriptOptions.devtools = defu(scriptOptions.devtools, { registryKey })
Expand Down
128 changes: 128 additions & 0 deletions test/unit/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest'
import { useRegistryScript } from '../../src/runtime/utils'

// Mock dependencies
vi.mock('nuxt/app', () => ({
useRuntimeConfig: () => ({ public: { scripts: {} } }),
}))

vi.mock('../../src/runtime/composables/useScript', () => ({
useScript: vi.fn((input, options) => ({ input, options })),
}))

vi.mock('#nuxt-scripts-validator', () => ({
parse: vi.fn(),
}))

describe('useRegistryScript query param merging', () => {
it('should merge query params when user provides custom src', () => {
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
scriptInput: {
src: 'https://example.com/script.js?id=123&auth=abc&existing=default',
},
}))

const userOptions = {
scriptInput: {
src: 'https://custom-domain.com/script.js?auth=override&new=param',
},
}

const result = useRegistryScript('test', mockOptionsFunction, userOptions)

// The options function should be called with the user options and context
expect(mockOptionsFunction).toHaveBeenCalledWith(
userOptions,
{ scriptInput: userOptions.scriptInput },
)

// Check the result contains merged query params (user params come first due to object spread)
expect(result.input.src).toBe('https://custom-domain.com/script.js?auth=override&new=param&id=123&existing=default')
})

it('should preserve user query params over default ones', () => {
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
scriptInput: {
src: 'https://example.com/script.js?param1=default&param2=default',
},
}))

const userOptions = {
scriptInput: {
src: 'https://custom-domain.com/script.js?param1=override',
},
}

const result = useRegistryScript('test', mockOptionsFunction, userOptions)

expect(result.input.src).toBe('https://custom-domain.com/script.js?param1=override&param2=default')
})

it('should handle cases where user src has no query params', () => {
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
scriptInput: {
src: 'https://example.com/script.js?id=123&auth=abc',
},
}))

const userOptions = {
scriptInput: {
src: 'https://custom-domain.com/script.js',
},
}

const result = useRegistryScript('test', mockOptionsFunction, userOptions)

expect(result.input.src).toBe('https://custom-domain.com/script.js?id=123&auth=abc')
})

it('should handle cases where default src has no query params', () => {
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
scriptInput: {
src: 'https://example.com/script.js',
},
}))

const userOptions = {
scriptInput: {
src: 'https://custom-domain.com/script.js?custom=param',
},
}

const result = useRegistryScript('test', mockOptionsFunction, userOptions)

expect(result.input.src).toBe('https://custom-domain.com/script.js?custom=param')
})

it('should not modify src when no user src is provided', () => {
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
scriptInput: {
src: 'https://example.com/script.js?id=123&auth=abc',
},
}))

const userOptions = {}

const result = useRegistryScript('test', mockOptionsFunction, userOptions)

expect(result.input.src).toBe('https://example.com/script.js?id=123&auth=abc')
})

it('should handle complex URLs with paths and fragments', () => {
const mockOptionsFunction = vi.fn((_opts, _ctx) => ({
scriptInput: {
src: 'https://example.com/path/to/script.js?id=123&version=1',
},
}))

const userOptions = {
scriptInput: {
src: 'https://custom-domain.com/custom/path/script.js?version=2&custom=true',
},
}

const result = useRegistryScript('test', mockOptionsFunction, userOptions)

expect(result.input.src).toBe('https://custom-domain.com/custom/path/script.js?version=2&custom=true&id=123')
})
})
Loading