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
58 changes: 48 additions & 10 deletions packages/script/src/runtime/registry/_gcm-consent.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
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 categories. Every entry takes `granted` or `denied`.
const CONSENT_CATEGORIES = [
'ad_storage',
'ad_user_data',
'ad_personalization',
'analytics_storage',
'functionality_storage',
'personalization_storage',
'security_storage',
] as const satisfies readonly (keyof ConsentState)[]

const CONSENT_CATEGORY_KEYS: ReadonlySet<string> = new Set(CONSENT_CATEGORIES)

// Fails to compile if `ConsentState` grows a key this validator does not know about.
type AssertNever<T extends never> = T
type _ConsentKeysChecked = AssertNever<Exclude<keyof ConsentState, typeof CONSENT_CATEGORIES[number] | 'wait_for_update' | 'region'>>

/**
* Describe what is wrong with one consent entry, or return `null` when it is valid.
* The canonical schema stays lenient so unknown keys pass a schema parse; here we
* reject them, because an unknown key is almost always a typo the user wants to see.
*/
function describeConsentIssue(key: string, value: unknown): string | null {
if (CONSENT_CATEGORY_KEYS.has(key)) {
return value === 'granted' || value === 'denied'
? null
: `"${key}" must be "granted" or "denied", received ${JSON.stringify(value)}`
}
if (key === 'wait_for_update') {
return typeof value === 'number' && Number.isFinite(value)
? null
: `"wait_for_update" must be a number, received ${JSON.stringify(value)}`
}
if (key === 'region') {
return Array.isArray(value) && value.every(entry => typeof entry === 'string')
? null
: `"region" must be an array of strings, received ${JSON.stringify(value)}`
}
return `unknown key "${key}"`
}

/**
* GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …).
Expand All@@ -21,11 +55,15 @@ export interface GcmConsentContract {

/** 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)
if (state === null || typeof state !== 'object') {
log.warn(`${source}: consent state must be an object, received ${JSON.stringify(state)}`)
return
for (const issue of result.issues)
log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p => p.key).join('.') || '<root>'})`)
}
for (const key in state) {
const issue = describeConsentIssue(key, (state as Record<string, unknown>)[key])
if (issue)
log.warn(`${source}: ${issue}`)
}
}

export function attachGcmConsent(
Expand Down
27 changes: 23 additions & 4 deletions packages/script/src/runtime/registry/speedcurve.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import type { LuxGlobal, UserConfig } from '@speedcurve/lux'
import type { InferInput } from 'valibot'
import type { RouteLocationNormalized } from 'vue-router'
import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types'
import { useHead, useNuxtApp, useRouter } from 'nuxt/app'
Expand All@@ -25,10 +26,28 @@ export type SpeedCurveInput = Omit<RegistryScriptInput<typeof SpeedCurveOptions>
label?: string | ((to: RouteLocationNormalized) => string | false) | false
}

// Derived from the schema: all schema keys except the composable-only ones.
const LUX_USER_CONFIG_KEYS = Object.keys(SpeedCurveOptions.entries).filter(
k => k !== 'id' && k !== 'autoTrackSpaNavigations' && k !== 'spaMode',
) as (keyof UserConfig)[]
/** Schema keys consumed by `useScriptSpeedCurve` itself, never forwarded to LUX. */
type ComposableOnlyKey = 'id' | 'spaMode' | 'autoTrackSpaNavigations'
type ForwardedKey = Exclude<keyof InferInput<typeof SpeedCurveOptions>, ComposableOnlyKey>

// Listed rather than derived from `SpeedCurveOptions.entries`, so a production build
// drops the schema and valibot with it. The type guards below fail to compile if this
// list and the schema drift apart.
const LUX_USER_CONFIG_KEYS = [
'label',
'samplerate',
'sendBeaconOnPageHidden',
'trackErrors',
'maxErrors',
'minMeasureTime',
'maxMeasureTime',
'newBeaconOnPageShow',
'trackHiddenPages',
'cookieDomain',
] as const satisfies readonly (ForwardedKey & keyof UserConfig)[]

type AssertNever<T extends never> = T
type _AllForwardedKeysListed = AssertNever<Exclude<ForwardedKey, typeof LUX_USER_CONFIG_KEYS[number]>>

let luxWired = false
let teardownAutoTracker = () => {}
Expand Down
53 changes: 53 additions & 0 deletions test/unit/gcm-consent.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
import type { ConsentState } from '../../packages/script/src/runtime/types'
import { describe, expect, it, vi } from 'vitest'
import { validateConsentState } from '../../packages/script/src/runtime/registry/_gcm-consent'

function collectWarnings(state: unknown): string[] {
const warn = vi.fn()
validateConsentState({ warn } as any, state as ConsentState, 'consent.update()')
return warn.mock.calls.map(([message]) => message as string)
}

describe('validateConsentState', () => {
it('accepts a valid partial GCMv2 state', () => {
expect(collectWarnings({
ad_storage: 'granted',
analytics_storage: 'denied',
wait_for_update: 500,
region: ['AU', 'NZ'],
})).toEqual([])
})

it('warns once per unknown key', () => {
const warnings = collectWarnings({ analytics_storages: 'granted', ad_storag: 'denied' })
expect(warnings).toHaveLength(2)
expect(warnings[0]).toContain('analytics_storages')
expect(warnings[1]).toContain('ad_storag')
})

it('warns on a consent value outside granted/denied', () => {
const warnings = collectWarnings({ ad_storage: 'allow' })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('ad_storage')
expect(warnings[0]).toContain('"allow"')
})

it('warns when wait_for_update is not a number', () => {
expect(collectWarnings({ wait_for_update: '500' })[0]).toContain('wait_for_update')
expect(collectWarnings({ wait_for_update: Number.NaN })[0]).toContain('wait_for_update')
})

it('warns when region is not an array of strings', () => {
expect(collectWarnings({ region: 'AU' })[0]).toContain('region')
expect(collectWarnings({ region: ['AU', 2] })[0]).toContain('region')
})

it('warns when the state is not an object', () => {
expect(collectWarnings(null)[0]).toContain('must be an object')
expect(collectWarnings('granted')[0]).toContain('must be an object')
})

it('reports the calling source in each warning', () => {
expect(collectWarnings({ nope: 'granted' })[0]).toContain('consent.update()')
})
})
Loading