@el7ven/cookie-kit 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Google Consent Mode v2 Provider
3
+ * Required for Google Ads, GA4, and GTM compliance
4
+ * @module GoogleConsentModeProvider
5
+ * @see https://developers.google.com/tag-platform/security/guides/consent
6
+ */
7
+
8
+ export class GoogleConsentModeProvider {
9
+ constructor(consentManager, config = {}) {
10
+ this.consentManager = consentManager
11
+ this.config = {
12
+ // Category mapping: CMP category → Google consent type
13
+ categoryMapping: {
14
+ analytics: ['analytics_storage'],
15
+ marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
16
+ preferences: ['functionality_storage', 'personalization_storage'],
17
+ ...config.categoryMapping
18
+ },
19
+ // Wait for GTM/GA to load
20
+ waitForGtag: true,
21
+ // Region-specific settings
22
+ regionSettings: {
23
+ EU: { wait_for_update: 500 },
24
+ UK: { wait_for_update: 500 },
25
+ US: { wait_for_update: 0 },
26
+ ...config.regionSettings
27
+ },
28
+ // Enable URL passthrough for conversion tracking
29
+ urlPassthrough: true,
30
+ // Enable ads data redaction
31
+ adsDataRedaction: true,
32
+ ...config
33
+ }
34
+
35
+ this.isInitialized = false
36
+ this.lastConsentHash = null // Prevent duplicate updates
37
+ this._debug('GoogleConsentModeProvider created')
38
+ }
39
+
40
+ /**
41
+ * Initialize Google Consent Mode
42
+ * MUST be called before GA/GTM loads
43
+ */
44
+ initialize() {
45
+ if (this.isInitialized) {
46
+ this._debug('Already initialized')
47
+ return
48
+ }
49
+
50
+ // Bootstrap gtag (CRITICAL: before GA/GTM loads)
51
+ this._bootstrapGtag()
52
+
53
+ // Set default consent state (DENIED for all)
54
+ this.setDefaultConsent()
55
+
56
+ // Listen for consent changes
57
+ this.consentManager.on('consentChanged', ({ consent, categories }) => {
58
+ this.updateConsent(categories, consent.region)
59
+ })
60
+
61
+ // Apply existing consent if available
62
+ const existingConsent = this.consentManager.getConsent()
63
+ if (existingConsent) {
64
+ this.updateConsent(existingConsent.categories, existingConsent.region)
65
+ }
66
+
67
+ this.isInitialized = true
68
+ this._debug('Google Consent Mode v2 initialized')
69
+ this._emitEvent('gcm:initialized', {})
70
+ }
71
+
72
+ /**
73
+ * Set default consent state (all DENIED)
74
+ * Called before GA/GTM loads
75
+ */
76
+ setDefaultConsent() {
77
+ const region = this.consentManager.getConsent()?.region || 'ROW'
78
+ const regionConfig = this.config.regionSettings[region] || {}
79
+
80
+ const defaultConsent = {
81
+ ad_storage: 'denied',
82
+ analytics_storage: 'denied',
83
+ ad_user_data: 'denied',
84
+ ad_personalization: 'denied',
85
+ functionality_storage: 'denied',
86
+ personalization_storage: 'denied',
87
+ security_storage: 'granted', // Always granted for security
88
+
89
+ // CRITICAL: wait_for_update gives CMP time to update consent
90
+ // Prevents GA from firing before consent update
91
+ wait_for_update: regionConfig.wait_for_update !== undefined
92
+ ? regionConfig.wait_for_update
93
+ : 500, // Default 500ms for all regions
94
+
95
+ ...regionConfig
96
+ }
97
+
98
+ // URL passthrough for conversion tracking without cookies
99
+ if (this.config.urlPassthrough) {
100
+ defaultConsent.url_passthrough = true
101
+ }
102
+
103
+ // Redact ads data until consent
104
+ if (this.config.adsDataRedaction) {
105
+ defaultConsent.ads_data_redaction = true
106
+ }
107
+
108
+ this._debug('Setting default consent:', defaultConsent)
109
+
110
+ window.gtag('consent', 'default', defaultConsent)
111
+
112
+ this._emitEvent('gcm:default-set', { consent: defaultConsent })
113
+ }
114
+
115
+ /**
116
+ * Update consent based on user choice
117
+ * @param {Object} categories - Consent categories
118
+ * @param {string} region - User region
119
+ */
120
+ updateConsent(categories, region = 'ROW') {
121
+ const consentUpdate = this._mapCategoriesToGoogleConsent(categories)
122
+
123
+ // Deduplication: prevent duplicate updates
124
+ const consentHash = this._hashConsent(consentUpdate)
125
+ if (this.lastConsentHash === consentHash) {
126
+ this._debug('Consent unchanged, skipping update')
127
+ return
128
+ }
129
+ this.lastConsentHash = consentHash
130
+
131
+ this._debug('Updating consent:', consentUpdate)
132
+
133
+ window.gtag('consent', 'update', consentUpdate)
134
+
135
+ this._emitEvent('gcm:updated', {
136
+ consent: consentUpdate,
137
+ categories,
138
+ region
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Map CMP categories to Google consent types
144
+ * @param {Object} categories - CMP categories
145
+ * @returns {Object} Google consent object
146
+ * @private
147
+ */
148
+ _mapCategoriesToGoogleConsent(categories) {
149
+ const googleConsent = {}
150
+
151
+ // Map each CMP category to Google consent types
152
+ Object.entries(this.config.categoryMapping).forEach(([cmpCategory, googleTypes]) => {
153
+ const isGranted = categories[cmpCategory] === true
154
+ const consentValue = isGranted ? 'granted' : 'denied'
155
+
156
+ googleTypes.forEach(googleType => {
157
+ googleConsent[googleType] = consentValue
158
+ })
159
+ })
160
+
161
+ // Security storage always granted
162
+ googleConsent.security_storage = 'granted'
163
+
164
+ return googleConsent
165
+ }
166
+
167
+ /**
168
+ * Get current consent state
169
+ * @returns {Object}
170
+ */
171
+ getConsentState() {
172
+ const consent = this.consentManager.getConsent()
173
+ if (!consent) {
174
+ return {
175
+ ad_storage: 'denied',
176
+ analytics_storage: 'denied',
177
+ ad_user_data: 'denied',
178
+ ad_personalization: 'denied'
179
+ }
180
+ }
181
+
182
+ return this._mapCategoriesToGoogleConsent(consent.categories)
183
+ }
184
+
185
+ /**
186
+ * Check if specific Google consent type is granted
187
+ * @param {string} consentType - Google consent type
188
+ * @returns {boolean}
189
+ */
190
+ isGranted(consentType) {
191
+ const state = this.getConsentState()
192
+ return state[consentType] === 'granted'
193
+ }
194
+
195
+ /**
196
+ * Manually trigger consent update
197
+ * Useful for testing or manual control
198
+ */
199
+ refresh() {
200
+ const consent = this.consentManager.getConsent()
201
+ if (consent) {
202
+ this.updateConsent(consent.categories, consent.region)
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Get category mapping configuration
208
+ * @returns {Object}
209
+ */
210
+ getCategoryMapping() {
211
+ return { ...this.config.categoryMapping }
212
+ }
213
+
214
+ /**
215
+ * Update category mapping
216
+ * @param {Object} mapping - New mapping
217
+ */
218
+ setCategoryMapping(mapping) {
219
+ this.config.categoryMapping = { ...this.config.categoryMapping, ...mapping }
220
+ this.refresh()
221
+ }
222
+
223
+ // --- Internal helpers ---
224
+
225
+ _emitEvent(eventName, detail) {
226
+ if (typeof window !== 'undefined') {
227
+ window.dispatchEvent(new CustomEvent(`cookie-consent:${eventName}`, { detail }))
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Bootstrap gtag function
233
+ * MUST run before GA/GTM loads
234
+ * @private
235
+ */
236
+ _bootstrapGtag() {
237
+ // Initialize dataLayer
238
+ window.dataLayer = window.dataLayer || []
239
+
240
+ // Create gtag function if not exists
241
+ if (!window.gtag) {
242
+ window.gtag = function() {
243
+ window.dataLayer.push(arguments)
244
+ }
245
+
246
+ // Mark gtag as bootstrapped by CMP
247
+ window.gtag.l = Date.now()
248
+ }
249
+
250
+ this._debug('gtag bootstrapped')
251
+ }
252
+
253
+ /**
254
+ * Hash consent object for deduplication
255
+ * @private
256
+ */
257
+ _hashConsent(consent) {
258
+ return JSON.stringify(consent)
259
+ }
260
+
261
+ _debug(...args) {
262
+ if (this.consentManager.config.debug) {
263
+ console.log('[GoogleConsentMode]', ...args)
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Helper function to initialize Google Consent Mode
270
+ * @param {ConsentManager} consentManager - Consent manager instance
271
+ * @param {Object} config - Configuration
272
+ * @returns {GoogleConsentModeProvider}
273
+ */
274
+ export function initGoogleConsentMode(consentManager, config = {}) {
275
+ const provider = new GoogleConsentModeProvider(consentManager, config)
276
+ provider.initialize()
277
+ return provider
278
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Consent Providers — integrations with external systems
3
+ * @module providers
4
+ */
5
+
6
+ export { GoogleConsentModeProvider, initGoogleConsentMode } from './GoogleConsentModeProvider.js'
@@ -0,0 +1,278 @@
1
+ /**
2
+ * ScriptRewriter — automatically rewrites scripts to be CMP-compliant
3
+ * @module ScriptRewriter
4
+ *
5
+ * Converts regular scripts:
6
+ * <script src="https://www.google-analytics.com/gtag.js"></script>
7
+ *
8
+ * Into CMP-compliant scripts:
9
+ * <script
10
+ * type="text/plain"
11
+ * data-cookie-category="analytics"
12
+ * data-cookie-src="https://www.google-analytics.com/gtag.js"
13
+ * ></script>
14
+ */
15
+
16
+ import { getAllTrackerPatterns } from '../trackers/TrackerPatterns.js'
17
+
18
+ export class ScriptRewriter {
19
+ constructor(config = {}) {
20
+ this.config = {
21
+ autoRewrite: true,
22
+ rewriteInline: false, // Rewrite inline scripts (dangerous!)
23
+ preserveAttributes: true, // Preserve original attributes
24
+ observeDOM: true, // Watch for new scripts
25
+ whitelist: [], // URLs to never rewrite
26
+ blacklist: [], // URLs to always rewrite
27
+ debug: false,
28
+ ...config
29
+ }
30
+
31
+ this.rewrittenScripts = new Map() // original script -> rewritten script
32
+ this.observer = null
33
+ }
34
+
35
+ /**
36
+ * Initialize rewriter
37
+ */
38
+ initialize() {
39
+ if (this.config.autoRewrite) {
40
+ this.rewriteAll()
41
+ }
42
+
43
+ if (this.config.observeDOM) {
44
+ this._startObserver()
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Rewrite all scripts on the page
50
+ * @returns {number} Number of scripts rewritten
51
+ */
52
+ rewriteAll() {
53
+ this._debug('Starting script rewriting...')
54
+
55
+ const scripts = document.querySelectorAll('script[src]')
56
+ let rewrittenCount = 0
57
+
58
+ scripts.forEach(script => {
59
+ if (this._shouldRewrite(script)) {
60
+ const rewritten = this._rewriteScript(script)
61
+ if (rewritten) {
62
+ rewrittenCount++
63
+ }
64
+ }
65
+ })
66
+
67
+ this._debug(`Rewrote ${rewrittenCount} scripts`)
68
+ return rewrittenCount
69
+ }
70
+
71
+ /**
72
+ * Rewrite a single script element
73
+ * @param {HTMLScriptElement} script - Script to rewrite
74
+ * @returns {HTMLScriptElement|null} Rewritten script or null
75
+ */
76
+ _rewriteScript(script) {
77
+ const src = script.getAttribute('src')
78
+ if (!src) return null
79
+
80
+ // Check if already rewritten
81
+ if (script.type === 'text/plain' && script.dataset.cookieSrc) {
82
+ this._debug('Script already rewritten:', src)
83
+ return null
84
+ }
85
+
86
+ // Detect tracker category
87
+ const category = this._detectCategory(src)
88
+ if (!category) {
89
+ this._debug('No category detected for:', src)
90
+ return null
91
+ }
92
+
93
+ // Create new script element
94
+ const newScript = document.createElement('script')
95
+
96
+ // Set CMP attributes
97
+ newScript.type = 'text/plain'
98
+ newScript.dataset.cookieCategory = category
99
+ newScript.dataset.cookieSrc = src
100
+
101
+ // Preserve original attributes
102
+ if (this.config.preserveAttributes) {
103
+ Array.from(script.attributes).forEach(attr => {
104
+ if (attr.name !== 'src' && attr.name !== 'type') {
105
+ newScript.setAttribute(attr.name, attr.value)
106
+ }
107
+ })
108
+ }
109
+
110
+ // Replace script
111
+ script.parentNode?.replaceChild(newScript, script)
112
+
113
+ // Store mapping
114
+ this.rewrittenScripts.set(script, newScript)
115
+
116
+ this._debug(`Rewrote script: ${src} → category: ${category}`)
117
+ return newScript
118
+ }
119
+
120
+ /**
121
+ * Check if script should be rewritten
122
+ * @param {HTMLScriptElement} script
123
+ * @returns {boolean}
124
+ */
125
+ _shouldRewrite(script) {
126
+ const src = script.getAttribute('src')
127
+ if (!src) return false
128
+
129
+ // Already rewritten
130
+ if (script.type === 'text/plain' && script.dataset.cookieSrc) {
131
+ return false
132
+ }
133
+
134
+ // Check whitelist (never rewrite)
135
+ if (this.config.whitelist.length > 0) {
136
+ const isWhitelisted = this.config.whitelist.some(pattern =>
137
+ src.includes(pattern)
138
+ )
139
+ if (isWhitelisted) {
140
+ this._debug('Whitelisted, skipping:', src)
141
+ return false
142
+ }
143
+ }
144
+
145
+ // Check blacklist (always rewrite)
146
+ if (this.config.blacklist.length > 0) {
147
+ const isBlacklisted = this.config.blacklist.some(pattern =>
148
+ src.includes(pattern)
149
+ )
150
+ if (isBlacklisted) {
151
+ return true
152
+ }
153
+ }
154
+
155
+ // Check if tracker is detected
156
+ const category = this._detectCategory(src)
157
+ return category !== null
158
+ }
159
+
160
+ /**
161
+ * Detect category from script src
162
+ * @param {string} src - Script source URL
163
+ * @returns {string|null} Category name or null
164
+ */
165
+ _detectCategory(src) {
166
+ const patterns = getAllTrackerPatterns()
167
+
168
+ for (const pattern of patterns) {
169
+ for (const scriptPattern of pattern.scripts) {
170
+ if (src.includes(scriptPattern)) {
171
+ return pattern.category
172
+ }
173
+ }
174
+ }
175
+
176
+ return null
177
+ }
178
+
179
+ /**
180
+ * Start MutationObserver to watch for new scripts
181
+ * @private
182
+ */
183
+ _startObserver() {
184
+ if (typeof MutationObserver === 'undefined') {
185
+ return
186
+ }
187
+
188
+ this.observer = new MutationObserver((mutations) => {
189
+ mutations.forEach((mutation) => {
190
+ mutation.addedNodes.forEach((node) => {
191
+ if (node.nodeType === 1) {
192
+ // Check if it's a script
193
+ if (node.tagName === 'SCRIPT' && node.getAttribute('src')) {
194
+ if (this._shouldRewrite(node)) {
195
+ this._debug('New script detected, rewriting...')
196
+ this._rewriteScript(node)
197
+ }
198
+ }
199
+
200
+ // Check for scripts inside added node
201
+ const scripts = node.querySelectorAll?.('script[src]')
202
+ scripts?.forEach(script => {
203
+ if (this._shouldRewrite(script)) {
204
+ this._rewriteScript(script)
205
+ }
206
+ })
207
+ }
208
+ })
209
+ })
210
+ })
211
+
212
+ this.observer.observe(document.documentElement, {
213
+ childList: true,
214
+ subtree: true
215
+ })
216
+
217
+ this._debug('Script rewriter observer started')
218
+ }
219
+
220
+ /**
221
+ * Get rewritten scripts
222
+ * @returns {Array}
223
+ */
224
+ getRewrittenScripts() {
225
+ return Array.from(this.rewrittenScripts.entries()).map(([original, rewritten]) => ({
226
+ original: original.src,
227
+ category: rewritten.dataset.cookieCategory,
228
+ rewritten: rewritten.dataset.cookieSrc
229
+ }))
230
+ }
231
+
232
+ /**
233
+ * Get stats
234
+ * @returns {Object}
235
+ */
236
+ getStats() {
237
+ const scripts = this.getRewrittenScripts()
238
+ const byCategory = {}
239
+
240
+ scripts.forEach(script => {
241
+ if (!byCategory[script.category]) {
242
+ byCategory[script.category] = []
243
+ }
244
+ byCategory[script.category].push(script.original)
245
+ })
246
+
247
+ return {
248
+ total: scripts.length,
249
+ byCategory
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Destroy rewriter
255
+ */
256
+ destroy() {
257
+ if (this.observer) {
258
+ this.observer.disconnect()
259
+ this.observer = null
260
+ }
261
+ this.rewrittenScripts.clear()
262
+ }
263
+
264
+ _debug(...args) {
265
+ // Debug disabled
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Helper function to rewrite scripts automatically
271
+ * @param {Object} config - Configuration
272
+ * @returns {ScriptRewriter}
273
+ */
274
+ export function initScriptRewriter(config = {}) {
275
+ const rewriter = new ScriptRewriter(config)
276
+ rewriter.initialize()
277
+ return rewriter
278
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Script Rewriting System — automatically rewrite scripts to be CMP-compliant
3
+ * @module rewriting
4
+ */
5
+
6
+ export { ScriptRewriter, initScriptRewriter } from './ScriptRewriter.js'