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
);
}