From 4fdc6c36290a832ab2478dc6b11ef8978a9b2733 Mon Sep 17 00:00:00 2001 From: RomanDenysov Date: Sat, 7 Feb 2026 20:10:38 +0100 Subject: [PATCH] feat!: flatten API, absorb GTM into core, remove @consentify/gtm - Add flat top-level methods (get, set, clear, isGranted, guard, subscribe, getServerSnapshot) with function overloads for precise return types - Move enableConsentMode and defaultConsentModeMapping from @consentify/gtm into @consentify/core - Export ConsentifySubscribable interface for adapters - Update React hook to consume flat API - Deduplicate cookie header construction (buildSetCookieHeader) - Add removeComments to build config (-11.7% gzip size) - Remove @consentify/gtm package and release workflow trigger - Update README with flat API examples and Google Consent Mode v2 docs - 82 tests passing BREAKING CHANGE: @consentify/gtm is removed. Use enableConsentMode from @consentify/core instead. The server/client namespaces are still available but the flat API is now the primary interface. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 6 - README.md | 102 +++++-- packages/core/src/index.test.ts | 460 +++++++++++++++++++++++++++++- packages/core/src/index.ts | 212 +++++++++++--- packages/core/tsconfig.build.json | 3 +- 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 - packages/react/src/index.ts | 22 +- 12 files changed, 719 insertions(+), 550 deletions(-) delete mode 100644 packages/gtm/LICENSE delete mode 100644 packages/gtm/package.json delete mode 100644 packages/gtm/src/index.test.ts delete mode 100644 packages/gtm/src/index.ts delete mode 100644 packages/gtm/tsconfig.build.json delete mode 100644 packages/gtm/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7344ca..44feb82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,6 @@ on: tags: - 'core-v*' - 'react-v*' - - 'gtm-v*' jobs: publish: @@ -34,8 +33,3 @@ jobs: run: pnpm -r --filter @consentify/react publish --access public --provenance --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish gtm - if: startsWith(github.ref, 'refs/tags/gtm-v') - run: pnpm -r --filter @consentify/gtm publish --access public --provenance --no-git-checks - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 4294a36..3ec554b 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,12 @@ const consent = createConsentify({ }); // Check consent (client-side) -consent.client.get('analytics'); // false — not yet granted +consent.isGranted('analytics'); // false — not yet granted // User accepts analytics -consent.client.set({ analytics: true }); +consent.set({ analytics: true }); -consent.client.get('analytics'); // true +consent.isGranted('analytics'); // true ``` ## The Full Integration: Blocking Google Analytics Until Consent @@ -52,7 +52,7 @@ export const consent = createConsentify({ ```ts // Load GA only when analytics consent is granted -consent.client.guard('analytics', () => { +consent.guard('analytics', () => { const s = document.createElement('script'); s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX'; s.async = true; @@ -70,7 +70,7 @@ If the user has already consented, the script loads immediately. If not, `guard( You can also handle revocation: ```ts -const dispose = consent.client.guard( +const dispose = consent.guard( 'marketing', () => loadPixel(), // runs when marketing consent is granted () => removePixel(), // runs if consent is later revoked @@ -85,19 +85,51 @@ dispose(); import { consent } from './lib/consent'; document.getElementById('accept-all')?.addEventListener('click', () => { - consent.client.set({ analytics: true, marketing: true }); + consent.set({ analytics: true, marketing: true }); }); document.getElementById('reject-all')?.addEventListener('click', () => { - consent.client.set({ analytics: false, marketing: false }); + consent.set({ analytics: false, marketing: false }); }); document.getElementById('reset')?.addEventListener('click', () => { - consent.client.clear(); + consent.clear(); window.location.reload(); }); ``` +## Google Consent Mode v2 + +Built-in support for Google Consent Mode v2. No extra package needed. + +```ts +import { createConsentify, enableConsentMode, defaultConsentModeMapping } from '@consentify/core'; + +const consent = createConsentify({ + policy: { categories: ['analytics', 'marketing', 'preferences'] as const }, +}); + +// Wire up Google Consent Mode with the default mapping +const dispose = enableConsentMode(consent, { + mapping: defaultConsentModeMapping, + waitForUpdate: 500, +}); +``` + +`enableConsentMode` automatically calls `gtag('consent', 'default', ...)` on init and `gtag('consent', 'update', ...)` whenever the user changes their choices. It bootstraps `dataLayer` and `gtag` if they don't exist. + +You can also provide a custom mapping: + +```ts +enableConsentMode(consent, { + mapping: { + necessary: ['security_storage'], + analytics: ['analytics_storage'], + marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'], + }, +}); +``` + ## React Integration ```bash @@ -126,10 +158,10 @@ export function CookieBanner() { return (

We use cookies to improve your experience.

- -
@@ -168,7 +200,7 @@ import { Analytics } from '../components/Analytics'; export default async function RootLayout({ children }: { children: React.ReactNode }) { const cookieStore = await cookies(); - const state = consent.server.get(cookieStore.toString()); + const state = consent.get(cookieStore.toString()); return ( @@ -189,8 +221,8 @@ import { consent } from '../../../lib/consent'; export async function POST(request: Request) { const { choices } = await request.json(); - const cookieHeader = request.headers.get('cookie'); - const setCookie = consent.server.set(choices, cookieHeader ?? undefined); + const cookieHeader = request.headers.get('cookie') ?? ''; + const setCookie = consent.set(choices, cookieHeader); const res = NextResponse.json({ ok: true }); res.headers.append('Set-Cookie', setCookie); @@ -198,13 +230,13 @@ export async function POST(request: Request) { } ``` -`client.getServerSnapshot()` always returns `{ decision: 'unset' }` during SSR, so hydration mismatches are impossible. +`getServerSnapshot()` always returns `{ decision: 'unset' }` during SSR, so hydration mismatches are impossible. ## API Reference ### `createConsentify(init)` -Returns `{ policy, server, client }`. +Returns a consent instance with flat top-level methods and `server`/`client` namespaces for advanced use. | Option | Type | Default | Description | |--------|------|---------|-------------| @@ -219,25 +251,48 @@ Returns `{ policy, server, client }`. | `consentMaxAgeDays` | `number` | — | Auto-expire consent after N days | | `storage` | `StorageKind[]` | `['cookie']` | Client storage priority (`'cookie'`, `'localStorage'`) | -### Server API +### Flat API (primary) | Method | Signature | Description | |--------|-----------|-------------| -| `server.get` | `(cookieHeader: string \| null \| undefined) => ConsentState` | Read consent from a `Cookie` header | -| `server.set` | `(choices: Partial>, currentCookieHeader?: string) => string` | Returns a `Set-Cookie` header string | -| `server.clear` | `() => string` | Returns a clearing `Set-Cookie` header | +| `get` | `() => ConsentState` | Current consent state (client-side) | +| `get` | `(cookieHeader: string) => ConsentState` | Read consent from a `Cookie` header (server-side) | +| `isGranted` | `(category: string) => boolean` | Check a single category (client-side) | +| `set` | `(choices: Partial>) => void` | Update consent choices (client-side) | +| `set` | `(choices: Partial>, cookieHeader: string) => string` | Returns a `Set-Cookie` header string (server-side) | +| `clear` | `() => void` | Clear all consent data (client-side) | +| `clear` | `(serverMode: string) => string` | Returns a clearing `Set-Cookie` header (server-side) | +| `guard` | `(category, onGrant, onRevoke?) => () => void` | Run code when consent is granted; optionally handle revocation. Returns a dispose function | +| `subscribe` | `(cb: () => void) => () => void` | Subscribe to changes (React-compatible) | +| `getServerSnapshot` | `() => ConsentState` | Always returns `{ decision: 'unset' }` for SSR | + +### Server / Client Namespaces (advanced) -### Client API +The `server` and `client` namespaces are still available for direct access: | Method | Signature | Description | |--------|-----------|-------------| +| `server.get` | `(cookieHeader: string \| null \| undefined) => ConsentState` | Read consent from a `Cookie` header | +| `server.set` | `(choices: Partial>, currentCookieHeader?: string) => string` | Returns a `Set-Cookie` header string | +| `server.clear` | `() => string` | Returns a clearing `Set-Cookie` header | | `client.get` | `() => ConsentState` | Current consent state | | `client.get` | `(category: string) => boolean` | Check a single category | | `client.set` | `(choices: Partial>) => void` | Update consent choices | | `client.clear` | `() => void` | Clear all consent data | -| `client.guard` | `(category, onGrant, onRevoke?) => () => void` | Run code when consent is granted; optionally handle revocation. Returns a dispose function | -| `client.subscribe` | `(cb: () => void) => () => void` | Subscribe to changes (React-compatible) | -| `client.getServerSnapshot` | `() => ConsentState` | Always returns `{ decision: 'unset' }` for SSR | +| `client.guard` | `(category, onGrant, onRevoke?) => () => void` | Guard with dispose | +| `client.subscribe` | `(cb: () => void) => () => void` | Subscribe to changes | +| `client.getServerSnapshot` | `() => ConsentState` | Always `{ decision: 'unset' }` | + +### `enableConsentMode(instance, options)` + +Wires Google Consent Mode v2 to a consent instance. Returns a dispose function. + +| Option | Type | Description | +|--------|------|-------------| +| `mapping` | `Partial>` | Maps consent categories to Google consent types | +| `waitForUpdate` | `number` | Milliseconds to wait before applying defaults (optional) | + +Google consent types: `ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage`, `functionality_storage`, `personalization_storage`, `security_storage`. ### `useConsentify(instance)` (React) @@ -258,7 +313,6 @@ 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 diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 50eefda..e76d764 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createConsentify, defaultCategories } from './index'; +import { createConsentify, defaultCategories, enableConsentMode, type ConsentifySubscribable, type ConsentState } from './index'; // --- Exported helper access (re-implement for testing since they're not exported) --- @@ -307,9 +307,21 @@ describe('client API', () => { const good = vi.fn(); c.client.subscribe(bad); c.client.subscribe(good); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); c.client.set({ analytics: true }); expect(bad).toHaveBeenCalled(); expect(good).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('subscribe() error is logged via console.error', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const err = new Error('boom'); + c.client.subscribe(() => { throw err; }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + c.client.set({ analytics: true }); + expect(spy).toHaveBeenCalledWith('[consentify] Listener callback threw:', err); + spy.mockRestore(); }); it('getServerSnapshot() always returns unset', () => { @@ -344,10 +356,12 @@ describe('storage fallback', () => { policy: { categories: ['analytics'] as const }, storage: ['localStorage', 'cookie'], }); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Should not throw expect(() => c.client.set({ analytics: true })).not.toThrow(); // Consent should be readable via the client API (cookie mirror worked) expect(c.client.get('analytics')).toBe(true); + spy.mockRestore(); window.localStorage.setItem = orig; }); }); @@ -509,3 +523,447 @@ describe('client.guard()', () => { expect(onGrant).toHaveBeenCalledTimes(1); }); }); + +// ============================================================ +// 10. Unified top-level API +// ============================================================ +describe('unified top-level API', () => { + beforeEach(clearAllCookies); + + it('get() delegates to client.get()', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + expect(c.get()).toEqual({ decision: 'unset' }); + c.client.set({ analytics: true }); + expect(c.get().decision).toBe('decided'); + }); + + it('get(cookieHeader) delegates to server.get()', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const snapshot = { + policy: c.policy.identifier, + givenAt: new Date().toISOString(), + choices: { necessary: true, analytics: true }, + }; + const header = `consentify=${enc(snapshot)}`; + const state = c.get(header); + expect(state.decision).toBe('decided'); + }); + + it('get(null) falls through to client.get()', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + expect(c.get(null)).toEqual({ decision: 'unset' }); + }); + + it('get("") delegates to server.get() and returns unset', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + expect(c.get('')).toEqual({ decision: 'unset' }); + }); + + it('isGranted("analytics") returns correct boolean', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + expect(c.isGranted('analytics')).toBe(false); + c.client.set({ analytics: true }); + expect(c.isGranted('analytics')).toBe(true); + }); + + it('isGranted("necessary") always returns true', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + expect(c.isGranted('necessary')).toBe(true); + }); + + it('set(choices) delegates to client.set()', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + c.set({ analytics: true }); + expect(c.client.get('analytics')).toBe(true); + }); + + it('set(choices, cookieHeader) returns Set-Cookie string', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const result = c.set({ analytics: true }, ''); + expect(typeof result).toBe('string'); + expect(result).toContain('consentify='); + }); + + it('clear() delegates to client.clear()', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + c.client.set({ analytics: true }); + expect(c.get().decision).toBe('decided'); + c.clear(); + expect(c.get()).toEqual({ decision: 'unset' }); + }); + + it('clear(cookieHeader) returns clearing header', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const result = c.clear('somecookie=value'); + expect(typeof result).toBe('string'); + expect(result).toContain('Max-Age=0'); + }); + + it('subscribe(cb) works at top level', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const cb = vi.fn(); + const unsub = c.subscribe(cb); + c.set({ analytics: true }); + expect(cb).toHaveBeenCalledTimes(1); + unsub(); + c.set({ analytics: false }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('guard() works at top level', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const onGrant = vi.fn(); + c.guard('analytics', onGrant); + expect(onGrant).not.toHaveBeenCalled(); + c.set({ analytics: true }); + expect(onGrant).toHaveBeenCalledTimes(1); + }); + + it('getServerSnapshot() returns unset', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + expect(c.getServerSnapshot()).toEqual({ decision: 'unset' }); + }); +}); + +// ============================================================ +// 11. enableConsentMode (Google Consent Mode v2) +// ============================================================ + +function findGtagCall(action: string, type: string): Record | undefined { + for (const entry of window.dataLayer as any[]) { + 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(() => { + delete (window as any).dataLayer; + delete (window as any).gtag; + clearAllCookies(); + 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; + Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true }); + + const dispose = enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + expect(dispose).toBeTypeOf('function'); + dispose(); + + 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'] }, + }); + + expect(window.dataLayer[0]).toEqual({ event: 'existing' }); + 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.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 set()', () => { + enableConsentMode(consent, { + mapping: { + analytics: ['analytics_storage'], + marketing: ['ad_storage', 'ad_user_data'], + }, + }); + + consent.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.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.set({ analytics: true }); + const countAfter = countGtagCalls('consent', 'update'); + + expect(countAfter).toBe(countBefore); + }); + + it('handles clear() (consent revoked)', () => { + enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + consent.set({ analytics: true }); + const updatesBefore = countGtagCalls('consent', 'update'); + + consent.clear(); + + const updatesAfter = countGtagCalls('consent', 'update'); + expect(updatesAfter).toBe(updatesBefore); + }); + + it('survives a throwing gtag and still subscribes', () => { + window.dataLayer = []; + window.gtag = vi.fn(() => { throw new Error('gtag broke'); }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const dispose = enableConsentMode(consent, { + mapping: { analytics: ['analytics_storage'] }, + }); + + // Should not throw — safeGtag catches it + expect(spy).toHaveBeenCalledWith( + '[consentify] gtag call failed:', + expect.any(Error), + ); + + // Subscription should still work — replace gtag with a working one + window.gtag = function gtag() { window.dataLayer.push(arguments); }; + consent.set({ analytics: true }); + expect(countGtagCalls('consent', 'update')).toBeGreaterThanOrEqual(1); + + dispose(); + spy.mockRestore(); + }); + + it('works with a minimal ConsentifySubscribable (not a full instance)', () => { + let state: ConsentState<'analytics'> = { decision: 'unset' }; + const listeners = new Set<() => void>(); + const subscribable: ConsentifySubscribable<'analytics'> = { + subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, + get: () => state, + getServerSnapshot: () => ({ decision: 'unset' }), + }; + + const dispose = enableConsentMode(subscribable, { + mapping: { analytics: ['analytics_storage'] }, + }); + + // Default call should have been made with denied + const defaultCall = findGtagCall('consent', 'default'); + expect(defaultCall).toBeDefined(); + expect(defaultCall!.analytics_storage).toBe('denied'); + + // Simulate consent decision + state = { + decision: 'decided', + snapshot: { + policy: 'x', + givenAt: new Date().toISOString(), + choices: { necessary: true, analytics: true }, + }, + }; + listeners.forEach(cb => cb()); + + const updateCall = findGtagCall('consent', 'update'); + expect(updateCall).toBeDefined(); + expect(updateCall!.analytics_storage).toBe('granted'); + + dispose(); + }); +}); + +// ============================================================ +// 12. Server API — merge & cookie config +// ============================================================ +describe('server API — merge & cookie config', () => { + it('server.set() merges with existing consent from currentCookieHeader', () => { + const c = createConsentify({ policy: { categories: ['analytics', 'marketing'] as const } }); + // First, set analytics via server + const header1 = c.server.set({ analytics: true }); + const cookieVal = header1.split(';')[0]; // "consentify=..." + // Now set marketing, passing existing cookie + const header2 = c.server.set({ marketing: true }, cookieVal); + const val = header2.split(';')[0].split('=').slice(1).join('='); + const snapshot = JSON.parse(decodeURIComponent(val)); + expect(snapshot.choices.analytics).toBe(true); + expect(snapshot.choices.marketing).toBe(true); + }); + + it('SameSite=None forces Secure flag in server headers', () => { + const c = createConsentify({ + policy: { categories: ['analytics'] }, + cookie: { sameSite: 'None', secure: false }, + }); + const header = c.server.set({ analytics: true }); + expect(header).toContain('SameSite=None'); + expect(header).toContain('Secure'); + }); + + it('domain option appears in Set-Cookie header', () => { + const c = createConsentify({ + policy: { categories: ['analytics'] }, + cookie: { domain: '.example.com' }, + }); + const header = c.server.set({ analytics: true }); + expect(header).toContain('Domain=.example.com'); + }); + + it('domain option appears in clear header', () => { + const c = createConsentify({ + policy: { categories: ['analytics'] }, + cookie: { domain: '.example.com' }, + }); + const header = c.server.clear(); + expect(header).toContain('Domain=.example.com'); + }); + + it('clear() returns the same header regardless of input', () => { + const c = createConsentify({ policy: { categories: ['analytics'] as const } }); + const result1 = c.clear('foo=bar'); + const result2 = c.clear('baz=qux'); + expect(result1).toBe(result2); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b7b679..44af0b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,7 +44,7 @@ export interface Snapshot { * High-level consent state derived from the presence of a valid snapshot. * When no valid snapshot exists for the current policy version, the state is `unset`. */ -export type ConsentState = +export type ConsentState = | {decision: 'unset'} | {decision: 'decided', snapshot: Snapshot} @@ -73,6 +73,16 @@ export interface CreateConsentifyInit { storage?: StorageKind[]; } +/** + * Minimal interface for subscribing to consent state changes. + * Used by `enableConsentMode` and other adapters that need reactive consent state. + */ +export interface ConsentifySubscribable { + subscribe: (callback: () => void) => () => void; + get: () => ConsentState; + getServerSnapshot: () => ConsentState; +} + function stableStringify(o: unknown): string { if (o === null || typeof o !== 'object') return JSON.stringify(o); if (Array.isArray(o)) return `[${o.map(stableStringify).join(',')}]`; @@ -116,21 +126,24 @@ function isValidSnapshot(s: unknown): s is Snapshot { return true; } +type CookieOpt = { maxAgeSec: number; sameSite: 'Lax'|'Strict'|'None'; secure: boolean; path: string; domain?: string }; + +function buildSetCookieHeader(name: string, value: string, opt: CookieOpt): string { + let h = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`; + if (opt.domain) h += `; Domain=${opt.domain}`; + if (opt.secure) h += `; Secure`; + return h; +} + function readCookie(name: string, cookieStr?: string): string | null { const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : ''); if (!src) return null; const m = src.split(';').map(v => v.trim()).find(v => v.startsWith(name + '=')); return m ? m.slice(name.length + 1) : null; } -function writeCookie( - name: string, value: string, - opt: { maxAgeSec: number; sameSite: 'Lax'|'Strict'|'None'; secure: boolean; path: string; domain?: string } -): void { +function writeCookie(name: string, value: string, opt: CookieOpt): void { if (typeof document === 'undefined') return; - let c = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`; - if (opt.domain) c += `; Domain=${opt.domain}`; - if (opt.secure) c += `; Secure`; - document.cookie = c; + document.cookie = buildSetCookieHeader(name, value, opt); } // --- Unified Factory (single entry point) --- @@ -178,20 +191,21 @@ export function createConsentify(init: CreateConse const readFromStore = (kind: StorageKind): string | null => { switch (kind) { case 'cookie': return readCookie(cookieName); - case 'localStorage': try { return canLocal() ? window.localStorage.getItem(cookieName) : null; } catch { return null; } + case 'localStorage': try { return canLocal() ? window.localStorage.getItem(cookieName) : null; } catch (err) { console.warn('[consentify] localStorage read failed:', err); return null; } default: return null; } }; const writeToStore = (kind: StorageKind, value: string) => { switch (kind) { case 'cookie': writeCookie(cookieName, value, cookieCfg); break; - case 'localStorage': try { if (canLocal()) window.localStorage.setItem(cookieName, value); } catch { /* quota exceeded or access denied */ } break; + case 'localStorage': try { if (canLocal()) window.localStorage.setItem(cookieName, value); } catch (err) { console.warn('[consentify] localStorage write failed:', err); } break; } }; + const clearCookieHeader = () => buildSetCookieHeader(cookieName, '', { ...cookieCfg, maxAgeSec: 0 }); const clearStore = (kind: StorageKind) => { switch (kind) { - case 'cookie': if (isBrowser()) document.cookie = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}${cookieCfg.domain ? `; Domain=${cookieCfg.domain}` : ''}${cookieCfg.secure ? '; Secure' : ''}`; break; - case 'localStorage': try { if (canLocal()) window.localStorage.removeItem(cookieName); } catch { /* access denied */ } break; + case 'cookie': if (isBrowser()) document.cookie = clearCookieHeader(); break; + case 'localStorage': try { if (canLocal()) window.localStorage.removeItem(cookieName); } catch (err) { console.warn('[consentify] localStorage clear failed:', err); } break; } }; const firstAvailableStore = (): StorageKind => { @@ -231,13 +245,6 @@ export function createConsentify(init: CreateConse return !same; }; - function buildSetCookieHeader(name: string, value: string, opt: typeof cookieCfg): string { - let header = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`; - if (opt.domain) header += `; Domain=${opt.domain}`; - if (opt.secure) header += `; Secure`; - return header; - } - // ---- server API const server = { get: (cookieHeader: string | null | undefined): ConsentState => { @@ -261,15 +268,10 @@ export function createConsentify(init: CreateConse }; return buildSetCookieHeader(cookieName, enc(snapshot), cookieCfg); }, - clear: (): string => { - let h = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}`; - if (cookieCfg.domain) h += `; Domain=${cookieCfg.domain}`; - if (cookieCfg.secure) h += `; Secure`; - return h; - } + clear: (): string => clearCookieHeader() }; - // ========== NEW: Subscribe pattern for React ========== + // ========== Subscribe pattern for React ========== const listeners = new Set<() => void>(); const unsetState: ConsentState = { decision: 'unset' }; let cachedState: ConsentState = unsetState; @@ -284,7 +286,11 @@ export function createConsentify(init: CreateConse }; const notifyListeners = (): void => { - listeners.forEach(cb => { try { cb(); } catch { /* listener error must not break others */ } }); + listeners.forEach(cb => { + try { cb(); } catch (err) { + console.error('[consentify] Listener callback threw:', err); + } + }); }; // Init cache on browser @@ -295,19 +301,19 @@ export function createConsentify(init: CreateConse // ---- client API function clientGet(): ConsentState; - function clientGet(category: 'necessary' | T): boolean; - function clientGet(category?: 'necessary' | T): ConsentState | boolean { + function clientGet(category: Necessary | T): boolean; + function clientGet(category?: Necessary | T): ConsentState | boolean { // Return cached state for React compatibility if (typeof category === 'undefined') return cachedState; if (category === 'necessary') return true; - return cachedState.decision === 'decided' - ? !!cachedState.snapshot.choices[category] + return cachedState.decision === 'decided' + ? !!cachedState.snapshot.choices[category] : false; } const client = { get: clientGet, - + set: (choices: Partial>) => { const fresh = readClient(); const base = fresh ? fresh.choices : normalize(); @@ -322,20 +328,18 @@ export function createConsentify(init: CreateConse notifyListeners(); } }, - + clear: () => { for (const k of new Set([...storageOrder, 'cookie'])) clearStore(k); syncState(); notifyListeners(); }, - // NEW: Subscribe for React useSyncExternalStore subscribe: (callback: () => void): (() => void) => { listeners.add(callback); return () => listeners.delete(callback); }, - // NEW: Server snapshot for SSR (always unset) getServerSnapshot: (): ConsentState => unsetState, guard: ( @@ -344,7 +348,7 @@ export function createConsentify(init: CreateConse onRevoke?: () => void, ): (() => void) => { let phase: 'waiting' | 'granted' | 'done' = 'waiting'; - const check = () => clientGet(category as any) === true; + const check = () => clientGet(category as Necessary | T) === true; const tick = () => { if (phase === 'waiting' && check()) { @@ -365,6 +369,30 @@ export function createConsentify(init: CreateConse }, }; + // --- Flat top-level API (overloaded for precise return types) --- + function flatGet(): ConsentState; + function flatGet(cookieHeader: string): ConsentState; + function flatGet(cookieHeader: null): ConsentState; + function flatGet(cookieHeader?: string | null): ConsentState { + return typeof cookieHeader === 'string' + ? server.get(cookieHeader) + : client.get(); + } + + function flatSet(choices: Partial>): void; + function flatSet(choices: Partial>, cookieHeader: string): string; + function flatSet(choices: Partial>, cookieHeader?: string): string | void { + if (typeof cookieHeader === 'string') return server.set(choices, cookieHeader); + client.set(choices); + } + + function flatClear(): void; + function flatClear(serverMode: string): string; + function flatClear(serverMode?: string): string | void { + if (typeof serverMode === 'string') return server.clear(); + client.clear(); + } + return { policy: { categories: init.policy.categories, @@ -372,9 +400,117 @@ export function createConsentify(init: CreateConse }, server, client, + + get: flatGet, + isGranted: (category: Necessary | T): boolean => { + return clientGet(category as Necessary | T); + }, + set: flatSet, + clear: flatClear, + subscribe: client.subscribe, + getServerSnapshot: client.getServerSnapshot, + guard: client.guard, } as const; } // Common predefined category names you can reuse in your policy. export const defaultCategories = ['preferences','analytics','marketing','functional','unclassified'] as const; -export type DefaultCategory = typeof defaultCategories[number]; \ No newline at end of file +export type DefaultCategory = typeof defaultCategories[number]; + +// --- Google Consent Mode v2 --- + +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; +} + +export const defaultConsentModeMapping = { + necessary: ['security_storage'], + analytics: ['analytics_storage'], + marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'], + preferences: ['functionality_storage', 'personalization_storage'], +} as const satisfies Record; + +declare global { + interface Window { + dataLayer: unknown[]; + gtag: (...args: unknown[]) => void; + } +} + +function safeGtag(...args: unknown[]): void { + try { + window.gtag(...args); + } catch (err) { + console.error('[consentify] gtag call failed:', err); + } +} + +export function enableConsentMode( + instance: ConsentifySubscribable, + options: ConsentModeOptions, +): () => void { + if (typeof window === 'undefined') return () => {}; + + window.dataLayer = window.dataLayer || []; + + 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.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; + }; + + const defaultPayload: Record = { ...resolve() }; + if (options.waitForUpdate != null) { + defaultPayload.wait_for_update = options.waitForUpdate; + } + safeGtag('consent', 'default', defaultPayload); + + const state = instance.get(); + if (state.decision === 'decided') { + safeGtag('consent', 'update', resolve()); + } + + const unsubscribe = instance.subscribe(() => { + const current = instance.get(); + if (current.decision === 'decided') { + safeGtag('consent', 'update', resolve()); + } + }); + + return unsubscribe; +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index f877ac0..5b82473 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -4,6 +4,7 @@ "outDir": "dist", "declaration": true, "emitDeclarationOnly": false, - "noEmit": false + "noEmit": false, + "removeComments": true } } \ No newline at end of file diff --git a/packages/gtm/LICENSE b/packages/gtm/LICENSE deleted file mode 100644 index 508cb71..0000000 --- a/packages/gtm/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index d6791a4..0000000 --- a/packages/gtm/package.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "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 deleted file mode 100644 index 967ca85..0000000 --- a/packages/gtm/src/index.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -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 deleted file mode 100644 index 8f43333..0000000 --- a/packages/gtm/src/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index b04d878..0000000 --- a/packages/gtm/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "declaration": true, - "emitDeclarationOnly": false, - "noEmit": false - } -} diff --git a/packages/gtm/tsconfig.json b/packages/gtm/tsconfig.json deleted file mode 100644 index 57f8e9b..0000000 --- a/packages/gtm/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM"], - "moduleResolution": "node", - "noEmit": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 982349c..b4363ad 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,25 +1,15 @@ "use client"; import { useSyncExternalStore } from "react"; -import type { ConsentState } from "@consentify/core"; - -interface ConsentifyClient { - subscribe: (callback: () => void) => () => void; - get: () => ConsentState; - getServerSnapshot: () => ConsentState; -} - -interface ConsentifyInstance { - client: ConsentifyClient; -} +import type { ConsentifySubscribable } from "@consentify/core"; export function useConsentify( - instance: ConsentifyInstance -): ConsentState { + instance: ConsentifySubscribable +): ReturnType { return useSyncExternalStore( - instance.client.subscribe, - instance.client.get, - instance.client.getServerSnapshot + instance.subscribe, + instance.get, + instance.getServerSnapshot ); }