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
6 changes: 3 additions & 3 deletions docs/content/docs/1.guides/3.consent.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const consent = useScriptTriggerConsent({

## Per-script consent API

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.
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.

```ts
const { consent } = useScriptGoogleAnalytics({
Expand All @@ -111,8 +111,8 @@ function onAcceptAll() {

| Script | `defaultConsent` | Runtime `consent.*` |
|---|---|---|
| Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} |
| Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} |
| Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.default(state)`{lang="ts"} / `consent.update(state)`{lang="ts"} |
| Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.default(state)`{lang="ts"} / `consent.update(state)`{lang="ts"} |
| Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} |
| Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} |
| TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} |
Expand Down
4 changes: 2 additions & 2 deletions docs/content/scripts/google-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The proxy exposes the `gtag` and `dataLayer` properties, and you should use them

## Consent Mode

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.
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.

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

`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.
`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.

### Per-region defaults

Expand Down
4 changes: 2 additions & 2 deletions docs/content/scripts/google-tag-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
::

::callout{icon="i-heroicons-information-circle"}
Nuxt Scripts only loads the GTM **container**. It does not auto-track page views, clicks, scrolls, or form submits on its own. What gets tracked is determined by the **tags and triggers configured inside your GTM workspace** (tagmanager.google.com), or by `dataLayer.push` calls you make from your app (see [Sending Page Events](#guide-sending-page-events)). For automatic page/click/scroll/video tracking, enable [GA4 Enhanced Measurement](https://support.google.com/analytics/answer/9216061) on your GA4 tag inside GTM.

Check warning on line 20 in docs/content/scripts/google-tag-manager.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is determined". Consider rewriting in active voice

Check warning on line 20 in docs/content/scripts/google-tag-manager.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is determined". Consider rewriting in active voice
::

::script-stats
Expand Down Expand Up @@ -48,7 +48,7 @@

## Consent Mode

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`.
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.

::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"}
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).
Expand Down Expand Up @@ -133,7 +133,7 @@

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.

`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`).
`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`).

::script-types
::
Expand Down
47 changes: 47 additions & 0 deletions packages/script/src/runtime/registry/_gcm-consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ConsentState, GcmConsentApi, UseScriptContext } from '../types'
import { safeParse, strictObject } from 'valibot'
import { logger } from '../logger'
import { gcmConsentState } from './schemas'

export type { GcmConsentApi }

// Strict variant rebuilt from the lenient schema's entries β€” same shape, but
// unknown keys produce issues so we can warn on typos without breaking the
// lenient `defaultConsent` schema parse used at build time.
const gcmConsentStateStrict = strictObject(gcmConsentState.entries)

/**
* GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …).
* `useRegistryScript` wires the `consent.default/update` API when present.
*/
export interface GcmConsentContract {
/** Forward a `consent`,`<action>`, `<state>` call to the script's transport (dataLayer or gtag). */
push: (proxy: any, action: 'default' | 'update', state: ConsentState) => void
}

/** Validate a partial GCMv2 consent state. Logs each issue via the registry-scoped logger. */
export function validateConsentState(log: typeof logger, state: ConsentState, source: string) {
const result = safeParse(gcmConsentStateStrict, state)
if (result.success)
return
for (const issue of result.issues)
log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p => p.key).join('.') || '<root>'})`)
}

export function attachGcmConsent(
instance: UseScriptContext<any, GcmConsentApi>,
contract: GcmConsentContract,
registryKey: string,
) {
if (instance.consent)
return
const log = logger.withTag(registryKey)
const push = (action: 'default' | 'update', state: ConsentState) => {
validateConsentState(log, state, `consent.${action}()`)
contract.push(instance.proxy, action, state)
}
instance.consent = {
default: (state: ConsentState) => push('default', state),
update: (state: ConsentState) => push('update', state),
}
}
25 changes: 9 additions & 16 deletions packages/script/src/runtime/registry/google-analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ConsentState, RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types'
import type { GcmConsentApi } from './_gcm-consent'
import { withQuery } from 'ufo'
import { useRegistryScript } from '#nuxt-scripts/utils'
import { GoogleAnalyticsOptions } from './schemas'
Expand Down Expand Up @@ -111,13 +112,11 @@ export { GoogleAnalyticsOptions }

export type GoogleAnalyticsInput = RegistryScriptInput<typeof GoogleAnalyticsOptions>

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

export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }): UseScriptContext<T, GoogleAnalyticsConsent> {
const instance = useRegistryScript<T, typeof GoogleAnalyticsOptions>(_options?.key || 'googleAnalytics', (options) => {
export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }): UseScriptContext<T, GcmConsentApi> {
return useRegistryScript<T, typeof GoogleAnalyticsOptions>(_options?.key || 'googleAnalytics', (options) => {
const dataLayerName = options?.l ?? 'dataLayer'
const w = import.meta.client ? window as any : {}
return {
Expand All @@ -133,6 +132,9 @@ export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?
}
},
},
gcmConsent: {
push: (proxy: any, action: 'default' | 'update', state: ConsentState) => (proxy as GoogleAnalyticsApi).gtag('consent', action, state as ConsentOptions),
},
clientInit: import.meta.server
? undefined
: () => {
Expand All @@ -157,14 +159,5 @@ export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?
}
},
}
}, _options) as UseScriptContext<T, GoogleAnalyticsConsent>

if (import.meta.client && !instance.consent) {
instance.consent = {
update: (state: ConsentState) => {
;(instance.proxy as unknown as GoogleAnalyticsApi).gtag('consent', 'update', state as ConsentOptions)
},
}
}
return instance
}, _options) as UseScriptContext<T, GcmConsentApi>
}
51 changes: 17 additions & 34 deletions packages/script/src/runtime/registry/google-tag-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ConsentState, NuxtUseScriptOptions, RegistryScriptInput, UseFunctionType, UseScriptContext } from '#nuxt-scripts/types'
import type { GcmConsentApi } from './_gcm-consent'
import type { GTag } from './google-analytics'
import { withQuery } from 'ufo'
import { useRegistryScript } from '#nuxt-scripts/utils'
Expand Down Expand Up @@ -80,10 +81,8 @@ export { GoogleTagManagerOptions }

export type GoogleTagManagerInput = RegistryScriptInput<typeof GoogleTagManagerOptions>

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

/**
* Hook to use Google Tag Manager in Nuxt applications
Expand All @@ -96,10 +95,8 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
*/
onBeforeGtmStart?: (gtag: DataLayerPush) => void
},
): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GoogleTagManagerConsent> {
const consentDataLayerName = options?.l ?? options?.dataLayer ?? 'dataLayer'

const instance = useRegistryScript<T, typeof GoogleTagManagerOptions>(
): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GcmConsentApi> {
return useRegistryScript<T, typeof GoogleTagManagerOptions>(
options?.key || 'googleTagManager',
(opts) => {
const dataLayerName = opts?.l ?? opts?.dataLayer ?? 'dataLayer'
Expand Down Expand Up @@ -128,6 +125,17 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
}
},
},
gcmConsent: {
// Match the gtag.js contract: enqueue an `Arguments` object, not a plain Array,
// so GTM/Tag Assistant/Analytics Debugger recognise the consent command. See #770/#771.
push: (_proxy: any, action: 'default' | 'update', state: ConsentState) => {
const dl = (window as any)[dataLayerName] = (window as any)[dataLayerName] || []
;(function (..._args: any[]) {
// eslint-disable-next-line prefer-rest-params
dl.push(arguments)
})('consent', action, state)
},
},
clientInit: import.meta.server
? undefined
: () => {
Expand Down Expand Up @@ -168,30 +176,5 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
}
},
options,
)

// Handle callback for cached/pre-initialized scripts (e.g., when ID is in nuxt.config)
if (import.meta.client && options?.onBeforeGtmStart) {
const gtag = (window as any).gtag
if (gtag)
options.onBeforeGtmStart(gtag)
}

const typed = instance as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GoogleTagManagerConsent>
if (import.meta.client && !typed.consent) {
typed.consent = {
update: (state: ConsentState) => {
const dl = (window as any)[consentDataLayerName] = (window as any)[consentDataLayerName] || []
// Must push the real `arguments` object β€” not a
// spread array β€” so GTM processes consent and
// other commands like the official snippet
;(function (..._args: any[]) {
// Rest params satisfy TypeScript call sites; gtm expects `arguments` on the queue.
// eslint-disable-next-line prefer-rest-params
dl.push(arguments)
})('consent', 'update', state)
},
}
}
return typed
) as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GcmConsentApi>
}
7 changes: 5 additions & 2 deletions packages/script/src/runtime/registry/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { any, array, boolean, custom, literal, minLength, number, object, option
// Shared GCMv2 consent category value.
const consentCategoryValue = union([literal('granted'), literal('denied')])

// Shared GCMv2 consent state (+ GA-only control fields).
const gcmConsentState = object({
// Shared GCMv2 consent state (+ GA-only control fields). Lenient at schema-parse
// time (extra keys pass through); the runtime `consent.*` API rebuilds a strict
// variant from these entries so typos surface as a `consola` warning without
// breaking the dev schema check.
export const gcmConsentState = object({
ad_storage: optional(consentCategoryValue),
ad_user_data: optional(consentCategoryValue),
ad_personalization: optional(consentCategoryValue),
Expand Down
11 changes: 11 additions & 0 deletions packages/script/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ export interface ConsentState {
wait_for_update?: number
}

/**
* Auto-attached `consent` API on scripts that adhere to the GCMv2 Consent Mode
* contract (Google Analytics, Google Tag Manager, …).
*/
export interface GcmConsentApi {
/** Push `['consent','default', state]` (or equivalent gtag call) with GCMv2 partial state. */
default: (state: ConsentState) => void
/** Push `['consent','update', state]` (or equivalent gtag call) with GCMv2 partial state. */
update: (state: ConsentState) => void
}

export type UseScriptContext<T extends Record<symbol | string, any>, C = unknown> = VueScriptInstance<T> & {
/**
* Remove and reload the script. Useful for scripts that need to re-execute
Expand Down
14 changes: 13 additions & 1 deletion packages/script/src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import type {
UseFunctionType,
UseScriptContext,
} from '#nuxt-scripts/types'
import type { GcmConsentContract } from './registry/_gcm-consent'
import { defu } from 'defu'
import { createError, useRuntimeConfig } from 'nuxt/app'
import { parseQuery, parseURL, withQuery } from 'ufo'
import { parse } from 'valibot'
import { useScript } from './composables/useScript'
import { createNpmScriptStub } from './npm-script-stub'
import { attachGcmConsent } from './registry/_gcm-consent'

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

export function scriptRuntimeConfig<T extends keyof ScriptRegistry>(key: T) {
Expand Down Expand Up @@ -173,5 +180,10 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
options.clientInit?.()
}
}
return useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)
const instance = useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)

if (import.meta.client && options.gcmConsent)
attachGcmConsent(instance as any, options.gcmConsent, String(registryKey))

return instance
}
Loading
Loading