diff --git a/docs/content/scripts/tracking/google-tag-manager.md b/docs/content/scripts/tracking/google-tag-manager.md index 2b39a2aa..0afcadef 100644 --- a/docs/content/scripts/tracking/google-tag-manager.md +++ b/docs/content/scripts/tracking/google-tag-manager.md @@ -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. @@ -62,7 +62,7 @@ export default defineNuxtConfig({ googleTagManager: { // .env // NUXT_PUBLIC_SCRIPTS_GOOGLE_TAG_MANAGER_ID= - id: '', + id: '', }, }, }, @@ -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 }) }) ``` @@ -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 + + +``` + +### Basic Usage Using Google Tag Manager only in production while using `dataLayer` to send a conversion event. diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 93f1ed91..98303a87 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -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' @@ -87,7 +87,7 @@ export type NuxtUseScriptOptions = {}> = registryMeta?: Record } /** - * @internal Used to run custom validation logic in dev mode. + * @internal */ _validate?: () => ValiError | null | undefined } diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 2dc1fc19..617cc90c 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -12,6 +12,7 @@ import type { UseFunctionType, ScriptRegistry, UseScriptContext, } from '#nuxt-scripts/types' +import { parseQuery, parseURL, withQuery } from 'ufo' export type MaybePromise = Promise | T @@ -22,14 +23,14 @@ function validateScriptInputSchema(key: string, schema: } catch (_e) { const e = _e as ValiError - 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 = (options: InferIfSchema) => ({ +type OptionsFn = (options: InferIfSchema, ctx: { scriptInput?: UseScriptInput & { src?: string } }) => ({ scriptInput?: UseScriptInput scriptOptions?: NuxtUseScriptOptions schema?: O extends ObjectSchema ? O : undefined @@ -43,9 +44,34 @@ export function scriptRuntimeConfig(key: T) { export function useRegistryScript, O = EmptyOptionsSchema>(registryKey: keyof ScriptRegistry | string, optionsFn: OptionsFn, _userOptions?: RegistryScriptInput): UseScriptContext, T>> { const scriptConfig = scriptRuntimeConfig(registryKey as keyof ScriptRegistry) const userOptions = Object.assign(_userOptions || {}, typeof scriptConfig === 'object' ? scriptConfig : {}) - const options = optionsFn(userOptions as InferIfSchema) + const options = optionsFn(userOptions as InferIfSchema, { 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 }) diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts new file mode 100644 index 00000000..4f7e07ab --- /dev/null +++ b/test/unit/utils.test.ts @@ -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¶m2=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¶m2=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') + }) +})