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+
importtype{ConsentState,GcmConsentApi,UseScriptContext}from'../types'
2+
import{safeParse,strictObject}from'valibot'
3+
import{logger}from'../logger'
4+
import{gcmConsentState}from'./schemas'
5+
6+
exporttype{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+
constgcmConsentStateStrict=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+
exportinterfaceGcmConsentContract{
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+
exportfunctionvalidateConsentState(log: typeoflogger,state: ConsentState,source: string){
24+
constresult=safeParse(gcmConsentStateStrict,state)
25+
if(result.success)
26+
return
27+
for(constissueofresult.issues)
28+
log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p=>p.key).join('.')||'<root>'})`)
29+
}
30+
31+
exportfunctionattachGcmConsent(
32+
instance: UseScriptContext<any,GcmConsentApi>,
33+
contract: GcmConsentContract,
34+
registryKey: string,
35+
){
36+
if(instance.consent)
37+
return
38+
constlog=logger.withTag(registryKey)
39+
constpush=(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
importtype{ConsentState,RegistryScriptInput,UseScriptContext}from'#nuxt-scripts/types'
2+
importtype{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
exporttypeGoogleAnalyticsInput=RegistryScriptInput<typeofGoogleAnalyticsOptions>
113114

114-
exportinterfaceGoogleAnalyticsConsent{
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+
exporttypeGoogleAnalyticsConsent=GcmConsentApi
118117

119-
exportfunctionuseScriptGoogleAnalytics<TextendsGoogleAnalyticsApi>(_options?: GoogleAnalyticsInput&{onBeforeGtagStart?: (gtag: GTag)=>void}): UseScriptContext<T,GoogleAnalyticsConsent>{
120-
constinstance=useRegistryScript<T,typeofGoogleAnalyticsOptions>(_options?.key||'googleAnalytics',(options)=>{
118+
exportfunctionuseScriptGoogleAnalytics<TextendsGoogleAnalyticsApi>(_options?: GoogleAnalyticsInput&{onBeforeGtagStart?: (gtag: GTag)=>void}): UseScriptContext<T,GcmConsentApi>{
119+
returnuseRegistryScript<T,typeofGoogleAnalyticsOptions>(_options?.key||'googleAnalytics',(options)=>{
121120
constdataLayerName=options?.l??'dataLayer'
122121
constw=import.meta.client ? windowasany : {}
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)=>(proxyasGoogleAnalyticsApi).gtag('consent',action,stateasConsentOptions),
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)asUseScriptContext<T,GoogleAnalyticsConsent>
161-
162-
if(import.meta.client&&!instance.consent){
163-
instance.consent={
164-
update: (state: ConsentState)=>{
165-
;(instance.proxyasunknownasGoogleAnalyticsApi).gtag('consent','update',stateasConsentOptions)
166-
},
167-
}
168-
}
169-
returninstance
162+
},_options)asUseScriptContext<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
importtype{ConsentState,NuxtUseScriptOptions,RegistryScriptInput,UseFunctionType,UseScriptContext}from'#nuxt-scripts/types'
2+
importtype{GcmConsentApi}from'./_gcm-consent'
23
importtype{GTag}from'./google-analytics'
34
import{withQuery}from'ufo'
45
import{useRegistryScript}from'#nuxt-scripts/utils'
@@ -80,10 +81,8 @@ export { GoogleTagManagerOptions }
8081

8182
exporttypeGoogleTagManagerInput=RegistryScriptInput<typeofGoogleTagManagerOptions>
8283

83-
exportinterfaceGoogleTagManagerConsent{
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+
exporttypeGoogleTagManagerConsent=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-
constconsentDataLayerName=options?.l??options?.dataLayer??'dataLayer'
101-
102-
constinstance=useRegistryScript<T,typeofGoogleTagManagerOptions>(
98+
): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>,T>,GcmConsentApi>{
99+
returnuseRegistryScript<T,typeofGoogleTagManagerOptions>(
103100
options?.key||'googleTagManager',
104101
(opts)=>{
105102
constdataLayerName=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+
constdl=(windowasany)[dataLayerName]=(windowasany)[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-
constgtag=(windowasany).gtag
176-
if(gtag)
177-
options.onBeforeGtmStart(gtag)
178-
}
179-
180-
consttyped=instanceasUseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>,T>,GoogleTagManagerConsent>
181-
if(import.meta.client&&!typed.consent){
182-
typed.consent={
183-
update: (state: ConsentState)=>{
184-
constdl=(windowasany)[consentDataLayerName]=(windowasany)[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-
returntyped
179+
)asUseScriptContext<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
constconsentCategoryValue=union([literal('granted'),literal('denied')])
55

6-
// Shared GCMv2 consent state (+ GA-only control fields).
7-
constgcmConsentState=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+
exportconstgcmConsentState=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+
exportinterfaceGcmConsentApi{
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
exporttypeUseScriptContext<TextendsRecord<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+
importtype{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
constURL_MATCH_RE=/https?:\/\/[^/]+\/_nuxt\/(.+\.vue)(?:\?[^)]*)?:(\d+):(\d+)/
@@ -42,6 +44,11 @@ type OptionsFn<O> = (options: InferIfSchema<O>, ctx: { scriptInput?: UseScriptIn
4244
schema?: OextendsObjectSchema<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
exportfunctionscriptRuntimeConfig<TextendskeyofScriptRegistry>(key: T){
@@ -173,5 +180,10 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
173180
options.clientInit?.()
174181
}
175182
}
176-
returnuseScript<T>(scriptInput,scriptOptionsasNuxtUseScriptOptions<T>)
183+
constinstance=useScript<T>(scriptInput,scriptOptionsasNuxtUseScriptOptions<T>)
184+
185+
if(import.meta.client&&options.gcmConsent)
186+
attachGcmConsent(instanceasany,options.gcmConsent,String(registryKey))
187+
188+
returninstance
177189
}

0 commit comments

Comments
 (0)