@el7ven/cookie-kit 0.2.21 → 0.3.2
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.
- package/package.json +21 -1
- package/src/core/AnalyticsManager.js +400 -0
- package/src/core/ConsentManager.js +710 -0
- package/src/core/ConsentMode.js +109 -0
- package/src/core/FocusTrap.js +130 -0
- package/src/core/GeoDetector.js +144 -0
- package/src/core/ScriptLoader.js +229 -0
- package/src/core/StorageAdapter.js +179 -0
- package/src/core/analytics.js +101 -10
- package/src/core/index.js +7 -7
- package/src/geo/GeoDetector.js +536 -0
- package/src/geo/RegionRules.js +126 -0
- package/src/geo/index.js +16 -0
- package/src/index.js +56 -18
- package/src/js/CookieConsent.js +0 -1
- package/src/locales/en.js +54 -0
- package/src/locales/index.js +20 -0
- package/src/locales/ro.js +54 -0
- package/src/plugins/CMPPlugin.js +187 -0
- package/src/plugins/PluginManager.js +234 -0
- package/src/plugins/index.js +7 -0
- package/src/providers/GoogleConsentModeProvider.js +278 -0
- package/src/providers/index.js +6 -0
- package/src/rewriting/ScriptRewriter.js +278 -0
- package/src/rewriting/index.js +6 -0
- package/src/scripts/ScriptLoader.js +310 -0
- package/src/scripts/ScriptManager.js +278 -0
- package/src/scripts/ScriptRegistry.js +175 -0
- package/src/scripts/ScriptScanner.js +178 -0
- package/src/scripts/index.js +9 -0
- package/src/trackers/TrackerDetector.js +488 -0
- package/src/trackers/TrackerPatterns.js +307 -0
- package/src/trackers/TrackerRegistry.js +172 -0
- package/src/trackers/index.js +15 -0
- package/src/utils/cookies.js +37 -0
- package/src/utils/dom.js +58 -0
- package/src/utils/helpers.js +89 -0
- package/src/vue/CookieConsent.vue +1 -1
- package/src/vue/CookieDrawer.vue +4 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Consent Mode v2 Integration
|
|
3
|
+
* Automatically syncs cookie consent with Google's Consent API
|
|
4
|
+
* @module ConsentMode
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CONSENT_MODE_DEFAULTS = {
|
|
8
|
+
analytics_storage: 'denied',
|
|
9
|
+
ad_storage: 'denied',
|
|
10
|
+
ad_user_data: 'denied',
|
|
11
|
+
ad_personalization: 'denied',
|
|
12
|
+
functionality_storage: 'denied',
|
|
13
|
+
personalization_storage: 'denied',
|
|
14
|
+
security_storage: 'granted'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Mapping: cookie category → Google consent types
|
|
18
|
+
const CATEGORY_MAPPING = {
|
|
19
|
+
necessary: ['security_storage'],
|
|
20
|
+
analytics: ['analytics_storage'],
|
|
21
|
+
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
|
|
22
|
+
preferences: ['functionality_storage', 'personalization_storage']
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ConsentMode {
|
|
26
|
+
constructor(consentManager, options = {}) {
|
|
27
|
+
this.manager = consentManager
|
|
28
|
+
this.enabled = options.enabled !== false
|
|
29
|
+
this.categoryMapping = { ...CATEGORY_MAPPING, ...options.categoryMapping }
|
|
30
|
+
this.waitForUpdate = options.waitForUpdate || 500
|
|
31
|
+
this._initialized = false
|
|
32
|
+
|
|
33
|
+
if (this.enabled) {
|
|
34
|
+
this._init()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_init() {
|
|
39
|
+
if (typeof window === 'undefined') return
|
|
40
|
+
if (this._initialized) return
|
|
41
|
+
|
|
42
|
+
// Ensure dataLayer exists
|
|
43
|
+
window.dataLayer = window.dataLayer || []
|
|
44
|
+
|
|
45
|
+
// Set default consent state (denied for everything except security)
|
|
46
|
+
this._gtag('consent', 'default', {
|
|
47
|
+
...CONSENT_MODE_DEFAULTS,
|
|
48
|
+
wait_for_update: this.waitForUpdate
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
this.manager._debug('ConsentMode: defaults set', CONSENT_MODE_DEFAULTS)
|
|
52
|
+
|
|
53
|
+
// Check if user already has consent
|
|
54
|
+
const existing = this.manager.getConsent()
|
|
55
|
+
if (existing?.hasConsented) {
|
|
56
|
+
this._updateFromCategories(existing.categories)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Listen for consent changes
|
|
60
|
+
this.manager.on('consentChanged', ({ categories }) => {
|
|
61
|
+
this._updateFromCategories(categories)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
this._initialized = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_updateFromCategories(categories) {
|
|
68
|
+
const consentUpdate = {}
|
|
69
|
+
|
|
70
|
+
Object.entries(this.categoryMapping).forEach(([category, gtagKeys]) => {
|
|
71
|
+
const granted = categories[category] === true
|
|
72
|
+
gtagKeys.forEach(key => {
|
|
73
|
+
consentUpdate[key] = granted ? 'granted' : 'denied'
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
this._gtag('consent', 'update', consentUpdate)
|
|
78
|
+
this.manager._debug('ConsentMode: updated', consentUpdate)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_gtag() {
|
|
82
|
+
if (typeof window === 'undefined') return
|
|
83
|
+
window.dataLayer = window.dataLayer || []
|
|
84
|
+
window.dataLayer.push(arguments)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get current consent mode state
|
|
88
|
+
getState() {
|
|
89
|
+
const consent = this.manager.getConsent()
|
|
90
|
+
if (!consent?.hasConsented) {
|
|
91
|
+
return { ...CONSENT_MODE_DEFAULTS }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const state = { ...CONSENT_MODE_DEFAULTS }
|
|
95
|
+
Object.entries(this.categoryMapping).forEach(([category, gtagKeys]) => {
|
|
96
|
+
const granted = consent.categories[category] === true
|
|
97
|
+
gtagKeys.forEach(key => {
|
|
98
|
+
state[key] = granted ? 'granted' : 'denied'
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
return state
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
destroy() {
|
|
105
|
+
this._initialized = false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { CONSENT_MODE_DEFAULTS, CATEGORY_MAPPING }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const FOCUSABLE_SELECTORS = [
|
|
2
|
+
'a[href]',
|
|
3
|
+
'button:not([disabled])',
|
|
4
|
+
'input:not([disabled])',
|
|
5
|
+
'select:not([disabled])',
|
|
6
|
+
'textarea:not([disabled])',
|
|
7
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
8
|
+
'[contenteditable]'
|
|
9
|
+
].join(', ')
|
|
10
|
+
|
|
11
|
+
export class FocusTrap {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.active = false
|
|
14
|
+
this.element = null
|
|
15
|
+
this.previousFocus = null
|
|
16
|
+
this.onEscape = options.onEscape || null
|
|
17
|
+
this._handleKeyDown = this._handleKeyDown.bind(this)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
activate(element) {
|
|
21
|
+
if (!element || typeof document === 'undefined') return
|
|
22
|
+
|
|
23
|
+
this.element = element
|
|
24
|
+
this.previousFocus = document.activeElement
|
|
25
|
+
this.active = true
|
|
26
|
+
|
|
27
|
+
element.setAttribute('role', 'dialog')
|
|
28
|
+
element.setAttribute('aria-modal', 'true')
|
|
29
|
+
|
|
30
|
+
document.addEventListener('keydown', this._handleKeyDown, { capture: true })
|
|
31
|
+
element.addEventListener('keydown', this._handleKeyDown, { capture: true })
|
|
32
|
+
|
|
33
|
+
requestAnimationFrame(() => {
|
|
34
|
+
const first = this._getFirstFocusable()
|
|
35
|
+
if (first) {
|
|
36
|
+
first.focus()
|
|
37
|
+
} else {
|
|
38
|
+
element.setAttribute('tabindex', '-1')
|
|
39
|
+
element.focus()
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
deactivate() {
|
|
45
|
+
if (!this.active) return
|
|
46
|
+
|
|
47
|
+
this.active = false
|
|
48
|
+
|
|
49
|
+
// Remove both listeners
|
|
50
|
+
document.removeEventListener('keydown', this._handleKeyDown, { capture: true })
|
|
51
|
+
if (this.element) {
|
|
52
|
+
this.element.removeEventListener('keydown', this._handleKeyDown, { capture: true })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Restore previous focus
|
|
56
|
+
if (this.previousFocus && typeof this.previousFocus.focus === 'function') {
|
|
57
|
+
this.previousFocus.focus()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.element = null
|
|
61
|
+
this.previousFocus = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_handleKeyDown(event) {
|
|
65
|
+
if (!this.active || !this.element) return
|
|
66
|
+
|
|
67
|
+
if (event.key === 'Escape') {
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
if (this.onEscape) {
|
|
70
|
+
this.onEscape()
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Tab key — trap focus
|
|
76
|
+
if (event.key === 'Tab') {
|
|
77
|
+
const focusables = this._getFocusableElements()
|
|
78
|
+
|
|
79
|
+
if (focusables.length === 0) {
|
|
80
|
+
event.preventDefault()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const first = focusables[0]
|
|
85
|
+
const last = focusables[focusables.length - 1]
|
|
86
|
+
const current = document.activeElement
|
|
87
|
+
|
|
88
|
+
if (event.shiftKey) {
|
|
89
|
+
if (current === first || !focusables.includes(current)) {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
last.focus()
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
if (current === last || !focusables.includes(current)) {
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
first.focus()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_getFocusableElements() {
|
|
103
|
+
if (!this.element) return []
|
|
104
|
+
|
|
105
|
+
return Array.from(this.element.querySelectorAll(FOCUSABLE_SELECTORS))
|
|
106
|
+
.filter(el => {
|
|
107
|
+
const style = window.getComputedStyle(el)
|
|
108
|
+
const isToggleInput = el.classList.contains('cookie-drawer__toggle-input')
|
|
109
|
+
|
|
110
|
+
let isVisible = style.display !== 'none' && style.visibility !== 'hidden'
|
|
111
|
+
|
|
112
|
+
if (!isToggleInput) {
|
|
113
|
+
isVisible = isVisible && el.offsetWidth > 0 && el.offsetHeight > 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isEnabled = !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
|
|
117
|
+
|
|
118
|
+
return isVisible && isEnabled
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_getFirstFocusable() {
|
|
123
|
+
const elements = this._getFocusableElements()
|
|
124
|
+
return elements[0] || null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
destroy() {
|
|
128
|
+
this.deactivate()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geo GDPR Detection — auto-detect user region for consent rules
|
|
3
|
+
* EU/UK → show banner, US → optional, other → configurable
|
|
4
|
+
* @module GeoDetector
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const EU_COUNTRIES = [
|
|
8
|
+
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
|
|
9
|
+
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
|
|
10
|
+
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO'
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const CCPA_STATES = ['CA']
|
|
14
|
+
|
|
15
|
+
const REGION_RULES = {
|
|
16
|
+
gdpr: { showBanner: true, requireExplicit: true, rejectButton: true },
|
|
17
|
+
ccpa: { showBanner: true, requireExplicit: false, rejectButton: true },
|
|
18
|
+
none: { showBanner: false, requireExplicit: false, rejectButton: false }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class GeoDetector {
|
|
22
|
+
constructor(consentManager, options = {}) {
|
|
23
|
+
this.manager = consentManager
|
|
24
|
+
this.defaultRegion = options.defaultRegion || 'unknown'
|
|
25
|
+
this.apiUrl = options.apiUrl || null
|
|
26
|
+
this.apiKey = options.apiKey || null
|
|
27
|
+
this.provider = options.provider || 'auto' // 'auto' | 'ipapi' | 'ipstack' | 'manual'
|
|
28
|
+
this._region = null
|
|
29
|
+
this._country = null
|
|
30
|
+
this._detected = false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect user's region
|
|
35
|
+
* @returns {Promise<{country: string, region: string, rules: Object}>}
|
|
36
|
+
*/
|
|
37
|
+
async detect() {
|
|
38
|
+
if (this._detected) {
|
|
39
|
+
return this.getResult()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (this.provider === 'manual') {
|
|
44
|
+
this._region = this.defaultRegion
|
|
45
|
+
this._detected = true
|
|
46
|
+
return this.getResult()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = await this._fetchGeoData()
|
|
50
|
+
this._country = data.country
|
|
51
|
+
this._region = this._classifyRegion(data.country, data.state)
|
|
52
|
+
this._detected = true
|
|
53
|
+
|
|
54
|
+
this.manager._debug('GeoDetector: detected', {
|
|
55
|
+
country: this._country,
|
|
56
|
+
region: this._region
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return this.getResult()
|
|
60
|
+
} catch (e) {
|
|
61
|
+
this.manager._debug('GeoDetector: detection failed, using default', e.message)
|
|
62
|
+
this._region = this.defaultRegion === 'gdpr' ? 'gdpr' : 'none'
|
|
63
|
+
this._detected = true
|
|
64
|
+
return this.getResult()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getResult() {
|
|
69
|
+
const region = this._region || 'gdpr' // default to gdpr for safety
|
|
70
|
+
return {
|
|
71
|
+
country: this._country,
|
|
72
|
+
region,
|
|
73
|
+
rules: REGION_RULES[region] || REGION_RULES.gdpr,
|
|
74
|
+
detected: this._detected
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if banner should be shown based on region
|
|
80
|
+
*/
|
|
81
|
+
shouldShowBanner() {
|
|
82
|
+
const result = this.getResult()
|
|
83
|
+
return result.rules.showBanner
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if explicit consent is required
|
|
88
|
+
*/
|
|
89
|
+
requiresExplicitConsent() {
|
|
90
|
+
const result = this.getResult()
|
|
91
|
+
return result.rules.requireExplicit
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Internal ---
|
|
95
|
+
|
|
96
|
+
_classifyRegion(country, state) {
|
|
97
|
+
if (EU_COUNTRIES.includes(country)) return 'gdpr'
|
|
98
|
+
if (country === 'US' && CCPA_STATES.includes(state)) return 'ccpa'
|
|
99
|
+
if (country === 'BR') return 'gdpr' // LGPD similar to GDPR
|
|
100
|
+
return 'none'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async _fetchGeoData() {
|
|
104
|
+
// Try multiple free providers
|
|
105
|
+
const providers = [
|
|
106
|
+
() => this._fetchIpApi(),
|
|
107
|
+
() => this._fetchCustom()
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
for (const provider of providers) {
|
|
111
|
+
try {
|
|
112
|
+
return await provider()
|
|
113
|
+
} catch (e) {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error('All geo providers failed')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async _fetchIpApi() {
|
|
122
|
+
const res = await fetch('https://ipapi.co/json/', {
|
|
123
|
+
signal: AbortSignal.timeout(3000)
|
|
124
|
+
})
|
|
125
|
+
if (!res.ok) throw new Error('ipapi failed')
|
|
126
|
+
const data = await res.json()
|
|
127
|
+
return {
|
|
128
|
+
country: data.country_code,
|
|
129
|
+
state: data.region_code
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async _fetchCustom() {
|
|
134
|
+
if (!this.apiUrl) throw new Error('No custom API URL')
|
|
135
|
+
const url = this.apiKey ? `${this.apiUrl}?key=${this.apiKey}` : this.apiUrl
|
|
136
|
+
const res = await fetch(url, {
|
|
137
|
+
signal: AbortSignal.timeout(3000)
|
|
138
|
+
})
|
|
139
|
+
if (!res.ok) throw new Error('Custom API failed')
|
|
140
|
+
return await res.json()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { EU_COUNTRIES, CCPA_STATES, REGION_RULES }
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script Loader System — blocks/unblocks scripts based on consent
|
|
3
|
+
* Supports inline and external scripts with data-cookiecategory attribute
|
|
4
|
+
* @module ScriptLoader
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class ScriptLoader {
|
|
8
|
+
constructor(consentManager) {
|
|
9
|
+
this.manager = consentManager
|
|
10
|
+
this.loadedScripts = new Map()
|
|
11
|
+
this.blockedScripts = []
|
|
12
|
+
this._observer = null
|
|
13
|
+
|
|
14
|
+
this._init()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_init() {
|
|
18
|
+
if (typeof window === 'undefined') return
|
|
19
|
+
|
|
20
|
+
// Scan existing scripts on page
|
|
21
|
+
this._scanBlockedScripts()
|
|
22
|
+
|
|
23
|
+
// Watch for new scripts added to DOM
|
|
24
|
+
this._observeDOM()
|
|
25
|
+
|
|
26
|
+
// Listen for consent changes
|
|
27
|
+
this.manager.on('consentChanged', ({ categories }) => {
|
|
28
|
+
this._onConsentChanged(categories)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Check if consent already exists
|
|
32
|
+
const consent = this.manager.getConsent()
|
|
33
|
+
if (consent?.hasConsented) {
|
|
34
|
+
this._onConsentChanged(consent.categories)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.manager._debug('ScriptLoader: initialized')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Public API ---
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register a script to load when category consent is granted
|
|
44
|
+
* @param {string} category - Cookie category id
|
|
45
|
+
* @param {string} src - Script URL
|
|
46
|
+
* @param {Object} options - Script attributes
|
|
47
|
+
*/
|
|
48
|
+
register(category, src, options = {}) {
|
|
49
|
+
const script = { category, src, options, loaded: false }
|
|
50
|
+
this.blockedScripts.push(script)
|
|
51
|
+
|
|
52
|
+
// Check if already consented
|
|
53
|
+
if (this.manager.hasCategoryConsent(category)) {
|
|
54
|
+
this._loadScript(script)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.manager._debug('ScriptLoader: registered', { category, src })
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load a script immediately (bypasses consent check)
|
|
63
|
+
*/
|
|
64
|
+
loadImmediate(src, options = {}) {
|
|
65
|
+
return this._injectScript(src, options)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove all scripts for a category
|
|
70
|
+
*/
|
|
71
|
+
removeCategory(category) {
|
|
72
|
+
this.loadedScripts.forEach((info, element) => {
|
|
73
|
+
if (info.category === category) {
|
|
74
|
+
element.remove()
|
|
75
|
+
this.loadedScripts.delete(element)
|
|
76
|
+
this.manager._debug('ScriptLoader: removed script', info.src)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get all registered scripts and their status
|
|
83
|
+
*/
|
|
84
|
+
getStatus() {
|
|
85
|
+
return this.blockedScripts.map(s => ({
|
|
86
|
+
category: s.category,
|
|
87
|
+
src: s.src,
|
|
88
|
+
loaded: s.loaded
|
|
89
|
+
}))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Internal ---
|
|
93
|
+
|
|
94
|
+
_scanBlockedScripts() {
|
|
95
|
+
if (typeof document === 'undefined') return
|
|
96
|
+
|
|
97
|
+
const scripts = document.querySelectorAll('script[data-cookiecategory]')
|
|
98
|
+
scripts.forEach(script => {
|
|
99
|
+
const category = script.getAttribute('data-cookiecategory')
|
|
100
|
+
const src = script.getAttribute('data-src') || script.src
|
|
101
|
+
|
|
102
|
+
if (script.type === 'text/plain') {
|
|
103
|
+
// Blocked script — store for later activation
|
|
104
|
+
this.blockedScripts.push({
|
|
105
|
+
category,
|
|
106
|
+
src,
|
|
107
|
+
options: { inline: !src, content: script.textContent },
|
|
108
|
+
element: script,
|
|
109
|
+
loaded: false
|
|
110
|
+
})
|
|
111
|
+
this.manager._debug('ScriptLoader: found blocked script', { category, src })
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_observeDOM() {
|
|
117
|
+
if (typeof MutationObserver === 'undefined') return
|
|
118
|
+
|
|
119
|
+
this._observer = new MutationObserver((mutations) => {
|
|
120
|
+
mutations.forEach(mutation => {
|
|
121
|
+
mutation.addedNodes.forEach(node => {
|
|
122
|
+
if (node.tagName === 'SCRIPT' && node.getAttribute('data-cookiecategory')) {
|
|
123
|
+
const category = node.getAttribute('data-cookiecategory')
|
|
124
|
+
if (node.type === 'text/plain') {
|
|
125
|
+
this.blockedScripts.push({
|
|
126
|
+
category,
|
|
127
|
+
src: node.getAttribute('data-src') || node.src,
|
|
128
|
+
options: { inline: !node.src, content: node.textContent },
|
|
129
|
+
element: node,
|
|
130
|
+
loaded: false
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
this._observer.observe(document.documentElement, {
|
|
139
|
+
childList: true,
|
|
140
|
+
subtree: true
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_onConsentChanged(categories) {
|
|
145
|
+
Object.entries(categories).forEach(([catId, granted]) => {
|
|
146
|
+
if (granted) {
|
|
147
|
+
// Load all scripts for this category
|
|
148
|
+
this.blockedScripts
|
|
149
|
+
.filter(s => s.category === catId && !s.loaded)
|
|
150
|
+
.forEach(s => this._loadScript(s))
|
|
151
|
+
} else {
|
|
152
|
+
// Remove scripts for rejected category
|
|
153
|
+
this.removeCategory(catId)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_loadScript(scriptInfo) {
|
|
159
|
+
if (scriptInfo.loaded) return
|
|
160
|
+
|
|
161
|
+
if (scriptInfo.options?.inline && scriptInfo.options?.content) {
|
|
162
|
+
// Inline script
|
|
163
|
+
this._injectInlineScript(scriptInfo)
|
|
164
|
+
} else if (scriptInfo.src) {
|
|
165
|
+
// External script
|
|
166
|
+
this._injectScript(scriptInfo.src, {
|
|
167
|
+
...scriptInfo.options,
|
|
168
|
+
category: scriptInfo.category
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
scriptInfo.loaded = true
|
|
173
|
+
this.manager._debug('ScriptLoader: loaded', scriptInfo.src || 'inline')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_injectScript(src, options = {}) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const script = document.createElement('script')
|
|
179
|
+
script.src = src
|
|
180
|
+
script.async = options.async !== false
|
|
181
|
+
|
|
182
|
+
if (options.id) script.id = options.id
|
|
183
|
+
if (options.defer) script.defer = true
|
|
184
|
+
if (options.crossOrigin) script.crossOrigin = options.crossOrigin
|
|
185
|
+
|
|
186
|
+
script.onload = () => {
|
|
187
|
+
this.manager._debug('ScriptLoader: script loaded', src)
|
|
188
|
+
resolve(script)
|
|
189
|
+
}
|
|
190
|
+
script.onerror = (err) => {
|
|
191
|
+
this.manager._debug('ScriptLoader: script error', src, err)
|
|
192
|
+
reject(err)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
document.head.appendChild(script)
|
|
196
|
+
|
|
197
|
+
this.loadedScripts.set(script, {
|
|
198
|
+
src,
|
|
199
|
+
category: options.category,
|
|
200
|
+
loaded: true
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_injectInlineScript(scriptInfo) {
|
|
206
|
+
const script = document.createElement('script')
|
|
207
|
+
script.textContent = scriptInfo.options.content
|
|
208
|
+
document.head.appendChild(script)
|
|
209
|
+
|
|
210
|
+
// Remove original blocked script
|
|
211
|
+
if (scriptInfo.element) {
|
|
212
|
+
scriptInfo.element.remove()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.loadedScripts.set(script, {
|
|
216
|
+
src: 'inline',
|
|
217
|
+
category: scriptInfo.category,
|
|
218
|
+
loaded: true
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
destroy() {
|
|
223
|
+
if (this._observer) {
|
|
224
|
+
this._observer.disconnect()
|
|
225
|
+
}
|
|
226
|
+
this.loadedScripts.clear()
|
|
227
|
+
this.blockedScripts = []
|
|
228
|
+
}
|
|
229
|
+
}
|