From 70534ac93a1bb22fe8ea8a7619a85c32fa653a75 Mon Sep 17 00:00:00 2001 From: RomanDenysov Date: Sat, 7 Feb 2026 15:22:15 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20@consentify/gtm=20=E2=80=94=20Goo?= =?UTF-8?q?gle=20Consent=20Mode=20v2=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin adapter that bridges consentify consent state to Google Consent Mode v2 via gtag('consent', 'default'|'update', ...). Maps consentify categories to Google consent types (ad_storage, analytics_storage, etc.) and keeps them in sync as consent changes. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- packages/gtm/LICENSE | 21 +++ packages/gtm/package.json | 79 ++++++++++ packages/gtm/src/index.test.ts | 245 +++++++++++++++++++++++++++++++ packages/gtm/src/index.ts | 98 +++++++++++++ packages/gtm/tsconfig.build.json | 9 ++ packages/gtm/tsconfig.json | 12 ++ pnpm-lock.yaml | 13 ++ 8 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 packages/gtm/LICENSE create mode 100644 packages/gtm/package.json create mode 100644 packages/gtm/src/index.test.ts create mode 100644 packages/gtm/src/index.ts create mode 100644 packages/gtm/tsconfig.build.json create mode 100644 packages/gtm/tsconfig.json diff --git a/README.md b/README.md index 0ca9664..4294a36 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ The `'necessary'` category is always `true` and cannot be disabled. When you cha |---------|-------------| | [@consentify/core](./packages/core) | Headless consent SDK -- TypeScript-first, SSR-safe, zero dependencies | | [@consentify/react](./packages/react) | React hook for @consentify/core | +| [@consentify/gtm](./packages/gtm) | Google Consent Mode v2 adapter | ## Coming Soon: Consentify SaaS @@ -272,7 +273,6 @@ A hosted consent management platform with a visual banner editor, analytics dash ## Roadmap -- `@consentify/gtm` -- Google Consent Mode v2 adapter - `@consentify/next` -- Next.js middleware with automatic cookie handling - Geo-aware consent defaults -- show banners only where required diff --git a/packages/gtm/LICENSE b/packages/gtm/LICENSE new file mode 100644 index 0000000..508cb71 --- /dev/null +++ b/packages/gtm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Roman Denysov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/gtm/package.json b/packages/gtm/package.json new file mode 100644 index 0000000..d6791a4 --- /dev/null +++ b/packages/gtm/package.json @@ -0,0 +1,79 @@ +{ + "name": "@consentify/gtm", + "version": "1.0.0", + "description": "Google Consent Mode v2 adapter for @consentify/core", + "author": { + "name": "Roman Denysov", + "url": "https://github.com/RomanDenysov" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/RomanDenysov" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/romandenysov" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/romandenysov" + } + ], + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9" + }, + "packageManager": "pnpm@9.12.3", + "keywords": [ + "cookie", + "consent", + "gdpr", + "ccpa", + "privacy", + "typescript", + "google", + "gtm", + "consent-mode" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/RomanDenysov/consentify.git", + "directory": "packages/gtm" + }, + "bugs": { + "url": "https://github.com/RomanDenysov/consentify/issues" + }, + "homepage": "https://github.com/RomanDenysov/consentify#readme", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "rimraf dist && tsc -p tsconfig.build.json", + "check": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**/*", + "README.md", + "LICENSE" + ], + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@consentify/core": "workspace:^" + }, + "devDependencies": { + "rimraf": "^6.0.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/gtm/src/index.test.ts b/packages/gtm/src/index.test.ts new file mode 100644 index 0000000..967ca85 --- /dev/null +++ b/packages/gtm/src/index.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createConsentify } from '@consentify/core'; +import { enableConsentMode } from './index'; + +function getGtagCalls(): unknown[][] { + return (window.dataLayer as unknown[][]); +} + +function findGtagCall(action: string, type: string): Record | undefined { + for (const entry of window.dataLayer as any[]) { + // dataLayer entries from gtag are Arguments objects, convert to array + const args = Array.from(entry); + if (args[0] === action && args[1] === type) { + return args[2] as Record; + } + } + return undefined; +} + +function countGtagCalls(action: string, type: string): number { + let count = 0; + for (const entry of window.dataLayer as any[]) { + const args = Array.from(entry); + if (args[0] === action && args[1] === type) count++; + } + return count; +} + +describe('enableConsentMode', () => { + let consent: ReturnType>; + + beforeEach(() => { + // Clean up window state + delete (window as any).dataLayer; + delete (window as any).gtag; + // Clear cookies + document.cookie.split(';').forEach(c => { + const name = c.split('=')[0].trim(); + if (name) document.cookie = `${name}=; Max-Age=0; Path=/`; + }); + // Clear localStorage + localStorage.clear(); + + consent = createConsentify({ + policy: { categories: ['analytics', 'marketing', 'preferences'] as const }, + }); + }); + + it('returns no-op dispose and makes no gtag calls in SSR', () => { + const origWindow = globalThis.window; + // Simulate SSR by temporarily hiding window + Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true }); + + const dispose = enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + expect(dispose).toBeTypeOf('function'); + dispose(); // should not throw + + // Restore + Object.defineProperty(globalThis, 'window', { value: origWindow, configurable: true }); + }); + + it('bootstraps dataLayer and gtag if missing', () => { + expect(window.dataLayer).toBeUndefined(); + expect(window.gtag).toBeUndefined(); + + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + expect(Array.isArray(window.dataLayer)).toBe(true); + expect(typeof window.gtag).toBe('function'); + }); + + it('preserves existing dataLayer and gtag', () => { + const existingData = [{ event: 'existing' }]; + window.dataLayer = existingData; + const customGtag = vi.fn(function gtag() { window.dataLayer.push(arguments); }); + window.gtag = customGtag; + + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + // Existing data should still be there + expect(window.dataLayer[0]).toEqual({ event: 'existing' }); + // Custom gtag should have been called + expect(customGtag).toHaveBeenCalled(); + }); + + it('calls gtag consent default on init with mapped types as denied', () => { + enableConsentMode(consent, { + mapping: { + analytics: ['analytics_storage'], + marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'], + }, + }); + + const defaultCall = findGtagCall('consent', 'default'); + expect(defaultCall).toBeDefined(); + expect(defaultCall!.analytics_storage).toBe('denied'); + expect(defaultCall!.ad_storage).toBe('denied'); + expect(defaultCall!.ad_user_data).toBe('denied'); + expect(defaultCall!.ad_personalization).toBe('denied'); + }); + + it('passes wait_for_update in default call when provided', () => { + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + waitForUpdate: 500, + }); + + const defaultCall = findGtagCall('consent', 'default'); + expect(defaultCall).toBeDefined(); + expect(defaultCall!.wait_for_update).toBe(500); + }); + + it('does not include wait_for_update when not provided', () => { + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + const defaultCall = findGtagCall('consent', 'default'); + expect(defaultCall).toBeDefined(); + expect(defaultCall!).not.toHaveProperty('wait_for_update'); + }); + + it('calls both default and update if consent already decided', () => { + consent.client.set({ analytics: true, marketing: false }); + + enableConsentMode(consent, { + mapping: { + analytics: ['analytics_storage'], + marketing: ['ad_storage'], + }, + }); + + expect(countGtagCalls('consent', 'default')).toBe(1); + expect(countGtagCalls('consent', 'update')).toBe(1); + + const updateCall = findGtagCall('consent', 'update'); + expect(updateCall!.analytics_storage).toBe('granted'); + expect(updateCall!.ad_storage).toBe('denied'); + }); + + it('only calls default if consent is unset', () => { + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + expect(countGtagCalls('consent', 'default')).toBe(1); + expect(countGtagCalls('consent', 'update')).toBe(0); + }); + + it('calls gtag consent update on client.set()', () => { + enableConsentMode(consent, { + mapping: { + analytics: ['analytics_storage'], + marketing: ['ad_storage', 'ad_user_data'], + }, + }); + + consent.client.set({ analytics: true, marketing: false }); + + const updateCalls = (window.dataLayer as any[]).filter(entry => { + const args = Array.from(entry); + return args[0] === 'consent' && args[1] === 'update'; + }); + + expect(updateCalls.length).toBeGreaterThanOrEqual(1); + const lastUpdate = Array.from(updateCalls[updateCalls.length - 1]) as unknown[]; + const payload = lastUpdate[2] as Record; + expect(payload.analytics_storage).toBe('granted'); + expect(payload.ad_storage).toBe('denied'); + expect(payload.ad_user_data).toBe('denied'); + }); + + it('maps multiple categories correctly', () => { + enableConsentMode(consent, { + mapping: { + analytics: ['analytics_storage'], + marketing: ['ad_storage'], + preferences: ['functionality_storage', 'personalization_storage'], + }, + }); + + consent.client.set({ analytics: true, marketing: false, preferences: true }); + + const updateCalls = (window.dataLayer as any[]).filter(entry => { + const args = Array.from(entry); + return args[0] === 'consent' && args[1] === 'update'; + }); + const lastUpdate = Array.from(updateCalls[updateCalls.length - 1]) as unknown[]; + const payload = lastUpdate[2] as Record; + + expect(payload.analytics_storage).toBe('granted'); + expect(payload.ad_storage).toBe('denied'); + expect(payload.functionality_storage).toBe('granted'); + expect(payload.personalization_storage).toBe('granted'); + }); + + it('maps necessary to granted always', () => { + enableConsentMode(consent, { + mapping: { + necessary: ['security_storage'], + analytics: ['analytics_storage'], + }, + }); + + const defaultCall = findGtagCall('consent', 'default'); + expect(defaultCall!.security_storage).toBe('granted'); + expect(defaultCall!.analytics_storage).toBe('denied'); + }); + + it('dispose stops future updates', () => { + const dispose = enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + dispose(); + + const countBefore = countGtagCalls('consent', 'update'); + consent.client.set({ analytics: true }); + const countAfter = countGtagCalls('consent', 'update'); + + expect(countAfter).toBe(countBefore); + }); + + it('handles client.clear() (consent revoked)', () => { + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + consent.client.set({ analytics: true }); + const updatesBefore = countGtagCalls('consent', 'update'); + + consent.client.clear(); + + // clear triggers subscribe callback, but decision is unset so no new update call + const updatesAfter = countGtagCalls('consent', 'update'); + expect(updatesAfter).toBe(updatesBefore); + }); +}); diff --git a/packages/gtm/src/index.ts b/packages/gtm/src/index.ts new file mode 100644 index 0000000..8f43333 --- /dev/null +++ b/packages/gtm/src/index.ts @@ -0,0 +1,98 @@ +import type { ConsentState } from '@consentify/core'; + +export type GoogleConsentType = + | 'ad_storage' + | 'ad_user_data' + | 'ad_personalization' + | 'analytics_storage' + | 'functionality_storage' + | 'personalization_storage' + | 'security_storage'; + +type GoogleConsentValue = 'granted' | 'denied'; + +export interface ConsentModeOptions { + mapping: Partial>; + waitForUpdate?: number; +} + +interface ConsentifyClient { + subscribe: (callback: () => void) => () => void; + get: () => ConsentState; +} + +interface ConsentifyInstance { + client: ConsentifyClient; +} + +declare global { + interface Window { + dataLayer: unknown[]; + gtag: (...args: unknown[]) => void; + } +} + +export function enableConsentMode( + instance: ConsentifyInstance, + options: ConsentModeOptions, +): () => void { + if (typeof window === 'undefined') return () => {}; + + // Ensure dataLayer exists + window.dataLayer = window.dataLayer || []; + + // Ensure gtag exists + if (typeof window.gtag !== 'function') { + window.gtag = function gtag() { + // eslint-disable-next-line prefer-rest-params + window.dataLayer.push(arguments); + }; + } + + const resolve = (): Record => { + const state = instance.client.get(); + const result: Record = {}; + + for (const [category, gTypes] of Object.entries(options.mapping) as [string, GoogleConsentType[]][]) { + if (!gTypes) continue; + + let granted = false; + if (category === 'necessary') { + granted = true; + } else if (state.decision === 'decided') { + granted = !!(state.snapshot.choices as Record)[category]; + } + + for (const gType of gTypes) { + result[gType] = granted ? 'granted' : 'denied'; + } + } + + return result; + }; + + // Default consent call + const defaultPayload: Record = { ...resolve() }; + if (options.waitForUpdate != null) { + defaultPayload.wait_for_update = options.waitForUpdate; + } + window.gtag('consent', 'default', defaultPayload); + + // If consent already decided, immediately send update + const state = instance.client.get(); + if (state.decision === 'decided') { + window.gtag('consent', 'update', resolve()); + } + + // Subscribe to future changes + const unsubscribe = instance.client.subscribe(() => { + const current = instance.client.get(); + if (current.decision === 'decided') { + window.gtag('consent', 'update', resolve()); + } + }); + + return unsubscribe; +} + +export * from '@consentify/core'; diff --git a/packages/gtm/tsconfig.build.json b/packages/gtm/tsconfig.build.json new file mode 100644 index 0000000..b04d878 --- /dev/null +++ b/packages/gtm/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "emitDeclarationOnly": false, + "noEmit": false + } +} diff --git a/packages/gtm/tsconfig.json b/packages/gtm/tsconfig.json new file mode 100644 index 0000000..57f8e9b --- /dev/null +++ b/packages/gtm/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "moduleResolution": "node", + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a76500..a7331e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,19 @@ importers: specifier: 5.9.3 version: 5.9.3 + packages/gtm: + dependencies: + '@consentify/core': + specifier: workspace:^ + version: link:../core + devDependencies: + rimraf: + specifier: ^6.0.0 + version: 6.0.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/react: dependencies: '@consentify/core':