Skip to content

Commit 6f74a6c

Browse files
authored
feat(gtm,ga): consent.default() + strict GCMv2 validation (#772)
1 parent fc3981b commit 6f74a6c

10 files changed

Lines changed: 194 additions & 75 deletions

File tree

docs/content/docs/1.guides/3.consent.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ const consent = useScriptTriggerConsent({
8989

9090
## Per-script consent API
9191

92-
Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update.
92+
Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update. For GCMv2 scripts (Google Analytics, Google Tag Manager), `consent.default(state)`{lang="ts"} is also available for runtime-derived defaults; both methods validate input against the canonical GCMv2 schema and warn via `consola` on unknown keys or non-`granted`/`denied` values.
9393

9494
```ts
9595
const { consent } = useScriptGoogleAnalytics({
@@ -111,8 +111,8 @@ function onAcceptAll() {
111111

112112
| Script | `defaultConsent` | Runtime `consent.*` |
113113
|---|---|---|
114-
| Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} |
115-
| Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} |
114+
| Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.default(state)`{lang="ts"} / `consent.update(state)`{lang="ts"} |
115+
| Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.default(state)`{lang="ts"} / `consent.update(state)`{lang="ts"} |
116116
| Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} |
117117
| Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} |
118118
| TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} |

docs/content/scripts/google-analytics.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The proxy exposes the `gtag` and `dataLayer` properties, and you should use them
3232

3333
## Consent Mode
3434

35-
Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', state)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime to flip categories.
35+
Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', state)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime to flip categories. For runtime-derived defaults (waiting for region/CMS to resolve before firing), call `consent.default()`{lang="ts"} from the client.
3636

3737
::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent" target="_blank"}
3838
Try the live [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com).
@@ -70,7 +70,7 @@ function savePreferences(choices: { analytics: boolean, marketing: boolean }) {
7070
</script>
7171
```
7272

73-
`consent.update()`{lang="ts"} accepts any `Partial<ConsentState>`{lang="ts"}; missing categories stay at their current value. For pre-`gtag('js')`{lang="ts"} setup beyond consent defaults, `onBeforeGtagStart` remains available as a general escape hatch.
73+
`consent.update()`{lang="ts"} and `consent.default()`{lang="ts"} both accept any `Partial<ConsentState>`{lang="ts"}; missing categories stay at their current value. Both methods validate input against the canonical GCMv2 schema and warn via `consola` on unknown keys or non-`granted`/`denied` values. For pre-`gtag('js')`{lang="ts"} setup beyond consent defaults, `onBeforeGtagStart` remains available as a general escape hatch.
7474

7575
### Per-region defaults
7676

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ useScriptEventPage(({ title, path }) => {
4848

4949
## Consent Mode
5050

51-
Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. Pass an **array** to `defaultConsent` to fire multiple defaults, for example [region-specific defaults](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior) where each entry targets different countries via `region`.
51+
Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. Pass an **array** to `defaultConsent` to fire multiple defaults, for example [region-specific defaults](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior) where each entry targets different countries via `region`. For runtime-derived defaults (waiting for region/CMS to resolve before queueing), call `consent.default()`{lang="ts"} from the client.
5252

5353
::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"}
5454
Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent), [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent), or [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com).
@@ -133,7 +133,7 @@ useScriptGoogleTagManager({
133133

134134
The module forwards each entry verbatim, in input order. Precedence between region-scoped and unscoped defaults is enforced by gtag at runtime, not by ordering.
135135

136-
`consent.update()`{lang="ts"} accepts any `Partial<ConsentState>`{lang="ts"}; missing categories stay at their current value. `onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`).
136+
`consent.update()`{lang="ts"} and `consent.default()`{lang="ts"} both accept any `Partial<ConsentState>`{lang="ts"}; missing categories stay at their current value. Both methods validate input against the canonical GCMv2 schema and warn via `consola` on unknown keys or non-`granted`/`denied` values. `onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`).
137137

138138
::script-types
139139
::
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { ConsentState, GcmConsentApi, UseScriptContext } from '../types'
2+
import { safeParse, strictObject } from 'valibot'
3+
import { logger } from '../logger'
4+
import { gcmConsentState } from './schemas'
5+
6+
export type { GcmConsentApi }
7+
8+
// Strict variant rebuilt from the lenient schema's entries — same shape, but
9+
// unknown keys produce issues so we can warn on typos without breaking the
10+
// lenient `defaultConsent` schema parse used at build time.
11+
const gcmConsentStateStrict = strictObject(gcmConsentState.entries)
12+
13+
/**
14+
* GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …).
15+
* `useRegistryScript` wires the `consent.default/update` API when present.
16+
*/
17+
export interface GcmConsentContract {
18+
/** Forward a `consent`,`<action>`, `<state>` call to the script's transport (dataLayer or gtag). */
19+
push: (proxy: any, action: 'default' | 'update', state: ConsentState) => void
20+
}
21+
22+
/** Validate a partial GCMv2 consent state. Logs each issue via the registry-scoped logger. */
23+
export function validateConsentState(log: typeof logger, state: ConsentState, source: string) {
24+
const result = safeParse(gcmConsentStateStrict, state)
25+
if (result.success)
26+
return
27+
for (const issue of result.issues)
28+
log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p => p.key).join('.') || '<root>'})`)
29+
}
30+
31+
export function attachGcmConsent(
32+
instance: UseScriptContext<any, GcmConsentApi>,
33+
contract: GcmConsentContract,
34+
registryKey: string,
35+
) {
36+
if (instance.consent)
37+
return
38+
const log = logger.withTag(registryKey)
39+
const push = (action: 'default' | 'update', state: ConsentState) => {
40+
validateConsentState(log, state, `consent.${action}()`)
41+
contract.push(instance.proxy, action, state)
42+
}
43+
instance.consent = {
44+
default: (state: ConsentState) => push('default', state),
45+
update: (state: ConsentState) => push('update', state),
46+
}
47+
}

packages/script/src/runtime/registry/google-analytics.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ConsentState, RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types'
2+
import type { GcmConsentApi } from './_gcm-consent'
23
import { withQuery } from 'ufo'
34
import { useRegistryScript } from '#nuxt-scripts/utils'
45
import { GoogleAnalyticsOptions } from './schemas'
@@ -111,13 +112,11 @@ export { GoogleAnalyticsOptions }
111112

112113
export type GoogleAnalyticsInput = RegistryScriptInput<typeof GoogleAnalyticsOptions>
113114

114-
export interface GoogleAnalyticsConsent {
115-
/** Send `gtag('consent','update', state)` with GCMv2 partial state. */
116-
update: (state: ConsentState) => void
117-
}
115+
/** @deprecated Use {@link GcmConsentApi} from `#nuxt-scripts/types` instead. */
116+
export type GoogleAnalyticsConsent = GcmConsentApi
118117

119-
export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }): UseScriptContext<T, GoogleAnalyticsConsent> {
120-
const instance = useRegistryScript<T, typeof GoogleAnalyticsOptions>(_options?.key || 'googleAnalytics', (options) => {
118+
export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }): UseScriptContext<T, GcmConsentApi> {
119+
return useRegistryScript<T, typeof GoogleAnalyticsOptions>(_options?.key || 'googleAnalytics', (options) => {
121120
const dataLayerName = options?.l ?? 'dataLayer'
122121
const w = import.meta.client ? window as any : {}
123122
return {
@@ -133,6 +132,9 @@ export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?
133132
}
134133
},
135134
},
135+
gcmConsent: {
136+
push: (proxy: any, action: 'default' | 'update', state: ConsentState) => (proxy as GoogleAnalyticsApi).gtag('consent', action, state as ConsentOptions),
137+
},
136138
clientInit: import.meta.server
137139
? undefined
138140
: () => {
@@ -157,14 +159,5 @@ export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?
157159
}
158160
},
159161
}
160-
}, _options) as UseScriptContext<T, GoogleAnalyticsConsent>
161-
162-
if (import.meta.client && !instance.consent) {
163-
instance.consent = {
164-
update: (state: ConsentState) => {
165-
;(instance.proxy as unknown as GoogleAnalyticsApi).gtag('consent', 'update', state as ConsentOptions)
166-
},
167-
}
168-
}
169-
return instance
162+
}, _options) as UseScriptContext<T, GcmConsentApi>
170163
}

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

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ConsentState, NuxtUseScriptOptions, RegistryScriptInput, UseFunctionType, UseScriptContext } from '#nuxt-scripts/types'
2+
import type { GcmConsentApi } from './_gcm-consent'
23
import type { GTag } from './google-analytics'
34
import { withQuery } from 'ufo'
45
import { useRegistryScript } from '#nuxt-scripts/utils'
@@ -80,10 +81,8 @@ export { GoogleTagManagerOptions }
8081

8182
export type GoogleTagManagerInput = RegistryScriptInput<typeof GoogleTagManagerOptions>
8283

83-
export interface GoogleTagManagerConsent {
84-
/** Send `gtag('consent','update', state)` so the dataLayer receives consent command (GCMv2 partial state). */
85-
update: (state: ConsentState) => void
86-
}
84+
/** @deprecated Use {@link GcmConsentApi} from `#nuxt-scripts/types` instead. */
85+
export type GoogleTagManagerConsent = GcmConsentApi
8786

8887
/**
8988
* Hook to use Google Tag Manager in Nuxt applications
@@ -96,10 +95,8 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
9695
*/
9796
onBeforeGtmStart?: (gtag: DataLayerPush) => void
9897
},
99-
): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GoogleTagManagerConsent> {
100-
const consentDataLayerName = options?.l ?? options?.dataLayer ?? 'dataLayer'
101-
102-
const instance = useRegistryScript<T, typeof GoogleTagManagerOptions>(
98+
): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GcmConsentApi> {
99+
return useRegistryScript<T, typeof GoogleTagManagerOptions>(
103100
options?.key || 'googleTagManager',
104101
(opts) => {
105102
const dataLayerName = opts?.l ?? opts?.dataLayer ?? 'dataLayer'
@@ -128,6 +125,17 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
128125
}
129126
},
130127
},
128+
gcmConsent: {
129+
// Match the gtag.js contract: enqueue an `Arguments` object, not a plain Array,
130+
// so GTM/Tag Assistant/Analytics Debugger recognise the consent command. See #770/#771.
131+
push: (_proxy: any, action: 'default' | 'update', state: ConsentState) => {
132+
const dl = (window as any)[dataLayerName] = (window as any)[dataLayerName] || []
133+
;(function (..._args: any[]) {
134+
// eslint-disable-next-line prefer-rest-params
135+
dl.push(arguments)
136+
})('consent', action, state)
137+
},
138+
},
131139
clientInit: import.meta.server
132140
? undefined
133141
: () => {
@@ -168,30 +176,5 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
168176
}
169177
},
170178
options,
171-
)
172-
173-
// Handle callback for cached/pre-initialized scripts (e.g., when ID is in nuxt.config)
174-
if (import.meta.client && options?.onBeforeGtmStart) {
175-
const gtag = (window as any).gtag
176-
if (gtag)
177-
options.onBeforeGtmStart(gtag)
178-
}
179-
180-
const typed = instance as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GoogleTagManagerConsent>
181-
if (import.meta.client && !typed.consent) {
182-
typed.consent = {
183-
update: (state: ConsentState) => {
184-
const dl = (window as any)[consentDataLayerName] = (window as any)[consentDataLayerName] || []
185-
// Must push the real `arguments` object — not a
186-
// spread array — so GTM processes consent and
187-
// other commands like the official snippet
188-
;(function (..._args: any[]) {
189-
// Rest params satisfy TypeScript call sites; gtm expects `arguments` on the queue.
190-
// eslint-disable-next-line prefer-rest-params
191-
dl.push(arguments)
192-
})('consent', 'update', state)
193-
},
194-
}
195-
}
196-
return typed
179+
) as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GcmConsentApi>
197180
}

packages/script/src/runtime/registry/schemas.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { any, array, boolean, custom, literal, minLength, number, object, option
33
// Shared GCMv2 consent category value.
44
const consentCategoryValue = union([literal('granted'), literal('denied')])
55

6-
// Shared GCMv2 consent state (+ GA-only control fields).
7-
const gcmConsentState = object({
6+
// Shared GCMv2 consent state (+ GA-only control fields). Lenient at schema-parse
7+
// time (extra keys pass through); the runtime `consent.*` API rebuilds a strict
8+
// variant from these entries so typos surface as a `consola` warning without
9+
// breaking the dev schema check.
10+
export const gcmConsentState = object({
811
ad_storage: optional(consentCategoryValue),
912
ad_user_data: optional(consentCategoryValue),
1013
ad_personalization: optional(consentCategoryValue),

packages/script/src/runtime/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ export interface ConsentState {
8080
wait_for_update?: number
8181
}
8282

83+
/**
84+
* Auto-attached `consent` API on scripts that adhere to the GCMv2 Consent Mode
85+
* contract (Google Analytics, Google Tag Manager, …).
86+
*/
87+
export interface GcmConsentApi {
88+
/** Push `['consent','default', state]` (or equivalent gtag call) with GCMv2 partial state. */
89+
default: (state: ConsentState) => void
90+
/** Push `['consent','update', state]` (or equivalent gtag call) with GCMv2 partial state. */
91+
update: (state: ConsentState) => void
92+
}
93+
8394
export type UseScriptContext<T extends Record<symbol | string, any>, C = unknown> = VueScriptInstance<T> & {
8495
/**
8596
* Remove and reload the script. Useful for scripts that need to re-execute

packages/script/src/runtime/utils.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import type {
99
UseFunctionType,
1010
UseScriptContext,
1111
} from '#nuxt-scripts/types'
12+
import type { GcmConsentContract } from './registry/_gcm-consent'
1213
import { defu } from 'defu'
1314
import { createError, useRuntimeConfig } from 'nuxt/app'
1415
import { parseQuery, parseURL, withQuery } from 'ufo'
1516
import { parse } from 'valibot'
1617
import { useScript } from './composables/useScript'
1718
import { createNpmScriptStub } from './npm-script-stub'
19+
import { attachGcmConsent } from './registry/_gcm-consent'
1820

1921
// Dev-only: stack trace parsing for component location detection (only referenced inside import.meta.dev)
2022
const URL_MATCH_RE = /https?:\/\/[^/]+\/_nuxt\/(.+\.vue)(?:\?[^)]*)?:(\d+):(\d+)/
@@ -42,6 +44,11 @@ type OptionsFn<O> = (options: InferIfSchema<O>, ctx: { scriptInput?: UseScriptIn
4244
schema?: O extends ObjectSchema<any, any> | UnionSchema<any, any> ? O : undefined
4345
clientInit?: () => void | Promise<any>
4446
scriptMode?: 'external' | 'npm' // NEW: external = CDN script (default), npm = NPM package only
47+
/**
48+
* Opt-in: this script consumes GCMv2 Consent Mode. `useRegistryScript` auto-attaches
49+
* a `consent: { default, update }` API + dev validation against the canonical schema.
50+
*/
51+
gcmConsent?: GcmConsentContract
4552
})
4653

4754
export function scriptRuntimeConfig<T extends keyof ScriptRegistry>(key: T) {
@@ -173,5 +180,10 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
173180
options.clientInit?.()
174181
}
175182
}
176-
return useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)
183+
const instance = useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)
184+
185+
if (import.meta.client && options.gcmConsent)
186+
attachGcmConsent(instance as any, options.gcmConsent, String(registryKey))
187+
188+
return instance
177189
}

0 commit comments

Comments
 (0)