@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,310 @@
1
+ /**
2
+ * ScriptLoader — safely injects scripts into DOM
3
+ * @module ScriptLoader
4
+ */
5
+
6
+ export class ScriptLoader {
7
+ constructor(config = {}) {
8
+ this.config = {
9
+ timeout: 10000, // 10 seconds
10
+ retryAttempts: 3,
11
+ retryDelay: 1000,
12
+ ...config
13
+ }
14
+ this.loadedScripts = new Set() // Track by src
15
+ this.loadedHashes = new Set() // Track by content hash
16
+ this.loadedIds = new Set() // Track by id
17
+ }
18
+
19
+ /**
20
+ * Load external script
21
+ * @param {Object} scriptDescriptor - Script descriptor from registry
22
+ * @returns {Promise<boolean>}
23
+ */
24
+ async loadExternal(scriptDescriptor) {
25
+ const { src, cookieSrc, attributes, id } = scriptDescriptor
26
+ const actualSrc = cookieSrc || src
27
+
28
+ if (!actualSrc) {
29
+ console.warn('[ScriptLoader] No src provided for external script')
30
+ return false
31
+ }
32
+
33
+ // Deduplication: check if already loaded
34
+ if (this.loadedScripts.has(actualSrc) || this.loadedIds.has(id)) {
35
+ console.log('[ScriptLoader] Script already loaded:', actualSrc)
36
+ return true
37
+ }
38
+
39
+ return new Promise((resolve) => {
40
+ const script = document.createElement('script')
41
+
42
+ // Set src from data-cookie-src (pre-blocking)
43
+ script.src = actualSrc
44
+
45
+ // Apply attributes
46
+ Object.entries(attributes || {}).forEach(([key, value]) => {
47
+ if (key !== 'src') {
48
+ script.setAttribute(key, value)
49
+ }
50
+ })
51
+
52
+ // Set proper type
53
+ script.type = 'text/javascript'
54
+
55
+ // Add data attribute for tracking
56
+ script.setAttribute('data-cookie-loaded', 'true')
57
+ script.setAttribute('data-cookie-id', id)
58
+
59
+ let timeoutId = null
60
+
61
+ const cleanup = () => {
62
+ if (timeoutId) clearTimeout(timeoutId)
63
+ script.removeEventListener('load', onLoad)
64
+ script.removeEventListener('error', onError)
65
+ }
66
+
67
+ const onLoad = () => {
68
+ cleanup()
69
+ this.loadedScripts.add(actualSrc)
70
+ this.loadedIds.add(id)
71
+ resolve(true)
72
+ }
73
+
74
+ const onError = (error) => {
75
+ cleanup()
76
+ console.error('[ScriptLoader] Failed to load script:', actualSrc, error)
77
+ resolve(false)
78
+ }
79
+
80
+ // Timeout
81
+ timeoutId = setTimeout(() => {
82
+ cleanup()
83
+ console.warn('[ScriptLoader] Script load timeout:', actualSrc)
84
+ resolve(false)
85
+ }, this.config.timeout)
86
+
87
+ script.addEventListener('load', onLoad)
88
+ script.addEventListener('error', onError)
89
+
90
+ // Inject into DOM
91
+ const target = document.head || document.documentElement
92
+ target.appendChild(script)
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Load inline script
98
+ * @param {Object} scriptDescriptor - Script descriptor from registry
99
+ * @returns {Promise<boolean>}
100
+ */
101
+ async loadInline(scriptDescriptor) {
102
+ const { content, attributes, id } = scriptDescriptor
103
+
104
+ if (!content) {
105
+ console.warn('[ScriptLoader] No content provided for inline script')
106
+ return false
107
+ }
108
+
109
+ // Deduplication: check by content hash
110
+ const contentHash = this._hashContent(content)
111
+ if (this.loadedHashes.has(contentHash) || this.loadedIds.has(id)) {
112
+ console.log('[ScriptLoader] Inline script already loaded')
113
+ return true
114
+ }
115
+
116
+ try {
117
+ const script = document.createElement('script')
118
+
119
+ // Set content
120
+ script.textContent = content
121
+
122
+ // Apply attributes
123
+ Object.entries(attributes || {}).forEach(([key, value]) => {
124
+ script.setAttribute(key, value)
125
+ })
126
+
127
+ // Set proper type
128
+ script.type = 'text/javascript'
129
+
130
+ // Add data attribute for tracking
131
+ script.setAttribute('data-cookie-loaded', 'true')
132
+ script.setAttribute('data-cookie-id', id)
133
+
134
+ // Inject into DOM
135
+ const target = document.head || document.documentElement
136
+ target.appendChild(script)
137
+
138
+ // Mark as loaded
139
+ this.loadedHashes.add(contentHash)
140
+ this.loadedIds.add(id)
141
+
142
+ return true
143
+ } catch (error) {
144
+ console.error('[ScriptLoader] Failed to load inline script:', error)
145
+ return false
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Load script (auto-detect inline vs external)
151
+ * @param {Object} scriptDescriptor - Script descriptor
152
+ * @returns {Promise<boolean>}
153
+ */
154
+ async load(scriptDescriptor) {
155
+ const { elementType } = scriptDescriptor
156
+
157
+ // Handle iframes
158
+ if (elementType === 'iframe') {
159
+ return this.loadIframe(scriptDescriptor)
160
+ }
161
+
162
+ // Handle scripts
163
+ if (scriptDescriptor.inline) {
164
+ return this.loadInline(scriptDescriptor)
165
+ } else {
166
+ return this.loadExternal(scriptDescriptor)
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Load iframe (YouTube, Maps, etc)
172
+ * @param {Object} iframeDescriptor - Iframe descriptor
173
+ * @returns {Promise<boolean>}
174
+ */
175
+ async loadIframe(iframeDescriptor) {
176
+ const { src, cookieSrc, attributes, id, element } = iframeDescriptor
177
+ const actualSrc = cookieSrc || src
178
+
179
+ if (!actualSrc) {
180
+ console.warn('[ScriptLoader] No src provided for iframe')
181
+ return false
182
+ }
183
+
184
+ // Deduplication
185
+ if (this.loadedScripts.has(actualSrc) || this.loadedIds.has(id)) {
186
+ console.log('[ScriptLoader] Iframe already loaded:', actualSrc)
187
+ return true
188
+ }
189
+
190
+ try {
191
+ // Update existing iframe or create new one
192
+ if (element && element.parentNode) {
193
+ // Update src on existing element
194
+ element.src = actualSrc
195
+
196
+ // Remove data-cookie-src attribute
197
+ element.removeAttribute('data-cookie-src')
198
+
199
+ this.loadedScripts.add(actualSrc)
200
+ this.loadedIds.add(id)
201
+ return true
202
+ } else {
203
+ // Create new iframe
204
+ const iframe = document.createElement('iframe')
205
+ iframe.src = actualSrc
206
+
207
+ // Apply attributes
208
+ Object.entries(attributes || {}).forEach(([key, value]) => {
209
+ iframe.setAttribute(key, value)
210
+ })
211
+
212
+ iframe.setAttribute('data-cookie-loaded', 'true')
213
+ iframe.setAttribute('data-cookie-id', id)
214
+
215
+ // Find parent or append to body
216
+ const target = element?.parentNode || document.body
217
+ target.appendChild(iframe)
218
+
219
+ this.loadedScripts.add(actualSrc)
220
+ this.loadedIds.add(id)
221
+ return true
222
+ }
223
+ } catch (error) {
224
+ console.error('[ScriptLoader] Failed to load iframe:', error)
225
+ return false
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Load multiple scripts sequentially
231
+ * @param {Array} scriptDescriptors - Array of script descriptors
232
+ * @returns {Promise<Object>} Results object
233
+ */
234
+ async loadMany(scriptDescriptors) {
235
+ const results = {
236
+ total: scriptDescriptors.length,
237
+ loaded: 0,
238
+ failed: 0,
239
+ scripts: []
240
+ }
241
+
242
+ for (const script of scriptDescriptors) {
243
+ const success = await this.load(script)
244
+
245
+ results.scripts.push({
246
+ id: script.id,
247
+ category: script.category,
248
+ success
249
+ })
250
+
251
+ if (success) {
252
+ results.loaded++
253
+ } else {
254
+ results.failed++
255
+ }
256
+ }
257
+
258
+ return results
259
+ }
260
+
261
+ /**
262
+ * Remove original blocked script element
263
+ * @param {HTMLScriptElement} element - Original script element
264
+ */
265
+ removeBlockedScript(element) {
266
+ if (element && element.parentNode) {
267
+ element.parentNode.removeChild(element)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Check if script is already loaded
273
+ * @param {string} src - Script src
274
+ * @returns {boolean}
275
+ */
276
+ isLoaded(src) {
277
+ return this.loadedScripts.has(src)
278
+ }
279
+
280
+ /**
281
+ * Clear loaded scripts cache
282
+ */
283
+ clear() {
284
+ this.loadedScripts.clear()
285
+ this.loadedHashes.clear()
286
+ this.loadedIds.clear()
287
+ }
288
+
289
+ /**
290
+ * Get loaded scripts count
291
+ * @returns {number}
292
+ */
293
+ getLoadedCount() {
294
+ return this.loadedScripts.size
295
+ }
296
+
297
+ /**
298
+ * Simple hash function for content deduplication
299
+ * @private
300
+ */
301
+ _hashContent(content) {
302
+ let hash = 0
303
+ for (let i = 0; i < content.length; i++) {
304
+ const char = content.charCodeAt(i)
305
+ hash = ((hash << 5) - hash) + char
306
+ hash = hash & hash
307
+ }
308
+ return hash.toString(36)
309
+ }
310
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * ScriptManager — main controller for consent-based script activation
3
+ * @module ScriptManager
4
+ */
5
+
6
+ import { ScriptScanner } from './ScriptScanner.js'
7
+ import { ScriptRegistry } from './ScriptRegistry.js'
8
+ import { ScriptLoader } from './ScriptLoader.js'
9
+
10
+ export class ScriptManager {
11
+ constructor(consentManager, config = {}) {
12
+ this.consentManager = consentManager
13
+ this.config = {
14
+ autoScan: true,
15
+ observeDOM: true,
16
+ removeBlockedScripts: true,
17
+ debug: false,
18
+ ...config
19
+ }
20
+
21
+ this.scanner = new ScriptScanner({
22
+ observeDOM: this.config.observeDOM
23
+ })
24
+
25
+ this.registry = new ScriptRegistry()
26
+ this.loader = new ScriptLoader()
27
+
28
+ this.isInitialized = false
29
+ this._debug('ScriptManager initialized')
30
+ }
31
+
32
+ /**
33
+ * Initialize script management system
34
+ */
35
+ async initialize() {
36
+ if (this.isInitialized) {
37
+ this._debug('Already initialized')
38
+ return
39
+ }
40
+
41
+ // Wait for DOM ready
42
+ if (document.readyState === 'loading') {
43
+ await new Promise(resolve => {
44
+ document.addEventListener('DOMContentLoaded', resolve, { once: true })
45
+ })
46
+ }
47
+
48
+ // Initial scan
49
+ if (this.config.autoScan) {
50
+ await this.scanAndRegister()
51
+ }
52
+
53
+ // Apply current consent
54
+ const consent = this.consentManager.getConsent()
55
+ if (consent) {
56
+ await this.applyConsent(consent.categories)
57
+ }
58
+
59
+ // Listen for consent changes
60
+ this.consentManager.on('consentChanged', async ({ categories }) => {
61
+ await this.applyConsent(categories)
62
+ })
63
+
64
+ // Observe DOM for new scripts
65
+ if (this.config.observeDOM) {
66
+ this.scanner.observe((newScripts) => {
67
+ this._debug('New scripts detected:', newScripts.length)
68
+ this.registry.addMany(newScripts)
69
+
70
+ // Auto-activate if consent already given
71
+ const consent = this.consentManager.getConsent()
72
+ if (consent) {
73
+ this.activateScriptsByConsent(newScripts, consent.categories)
74
+ }
75
+ })
76
+ }
77
+
78
+ this.isInitialized = true
79
+ this._debug('ScriptManager ready')
80
+ this._emitEvent('scripts:ready', { stats: this.registry.getStats() })
81
+ }
82
+
83
+ /**
84
+ * Scan DOM and register all scripts
85
+ */
86
+ async scanAndRegister() {
87
+ this._debug('Scanning DOM for scripts...')
88
+ const scripts = this.scanner.scan()
89
+ this._debug(`Found ${scripts.length} scripts`)
90
+
91
+ this.registry.addMany(scripts)
92
+
93
+ this._emitEvent('scripts:scanned', {
94
+ total: scripts.length,
95
+ stats: this.registry.getStats()
96
+ })
97
+
98
+ return scripts
99
+ }
100
+
101
+ /**
102
+ * Apply consent to scripts
103
+ * @param {Object} categories - Consent categories
104
+ */
105
+ async applyConsent(categories) {
106
+ this._debug('Applying consent:', categories)
107
+
108
+ const results = {
109
+ activated: [],
110
+ blocked: [],
111
+ failed: []
112
+ }
113
+
114
+ // Get all blocked scripts
115
+ const blockedScripts = this.registry.getBlocked()
116
+
117
+ for (const script of blockedScripts) {
118
+ const hasConsent = categories[script.category] === true
119
+
120
+ if (hasConsent) {
121
+ // Activate script
122
+ const success = await this.activateScript(script)
123
+
124
+ if (success) {
125
+ results.activated.push(script)
126
+ } else {
127
+ results.failed.push(script)
128
+ }
129
+ } else {
130
+ results.blocked.push(script)
131
+ }
132
+ }
133
+
134
+ this._debug('Consent applied:', {
135
+ activated: results.activated.length,
136
+ blocked: results.blocked.length,
137
+ failed: results.failed.length
138
+ })
139
+
140
+ this._emitEvent('scripts:consent-applied', results)
141
+
142
+ return results
143
+ }
144
+
145
+ /**
146
+ * Activate single script
147
+ * @param {Object} script - Script descriptor
148
+ * @returns {Promise<boolean>}
149
+ */
150
+ async activateScript(script) {
151
+ if (script.isActivated) {
152
+ this._debug('Script already activated:', script.id)
153
+ return true
154
+ }
155
+
156
+ this._debug('Activating script:', script.id, script.category)
157
+
158
+ try {
159
+ // Load script
160
+ const success = await this.loader.load(script)
161
+
162
+ if (success) {
163
+ // Mark as activated
164
+ this.registry.markActivated(script.id)
165
+
166
+ // Remove original blocked element
167
+ if (this.config.removeBlockedScripts && script.element) {
168
+ this.loader.removeBlockedScript(script.element)
169
+ }
170
+
171
+ this._emitEvent('script:activated', {
172
+ id: script.id,
173
+ category: script.category,
174
+ src: script.src || 'inline'
175
+ })
176
+
177
+ return true
178
+ }
179
+
180
+ return false
181
+ } catch (error) {
182
+ return false
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Activate scripts by consent (helper for new scripts)
188
+ * @param {Array} scripts - Scripts to check
189
+ * @param {Object} categories - Consent categories
190
+ */
191
+ async activateScriptsByConsent(scripts, categories) {
192
+ const results = []
193
+
194
+ for (const script of scripts) {
195
+ if (script.isBlocked && !script.isActivated) {
196
+ const hasConsent = categories[script.category] === true
197
+
198
+ if (hasConsent) {
199
+ const success = await this.activateScript(script)
200
+ results.push({ script, success })
201
+ }
202
+ }
203
+ }
204
+
205
+ return results
206
+ }
207
+
208
+ /**
209
+ * Get scripts by category
210
+ * @param {string} category - Category name
211
+ * @returns {Array}
212
+ */
213
+ getScriptsByCategory(category) {
214
+ return this.registry.getByCategory(category)
215
+ }
216
+
217
+ /**
218
+ * Get all registered scripts
219
+ * @returns {Array}
220
+ */
221
+ getAllScripts() {
222
+ return this.registry.getAll()
223
+ }
224
+
225
+ /**
226
+ * Get registry stats
227
+ * @returns {Object}
228
+ */
229
+ getStats() {
230
+ return this.registry.getStats()
231
+ }
232
+
233
+ /**
234
+ * Get blocked scripts count
235
+ * @returns {number}
236
+ */
237
+ getBlockedCount() {
238
+ return this.registry.getBlocked().length
239
+ }
240
+
241
+ /**
242
+ * Get activated scripts count
243
+ * @returns {number}
244
+ */
245
+ getActivatedCount() {
246
+ return this.registry.getActivated().length
247
+ }
248
+
249
+ /**
250
+ * Manually trigger rescan
251
+ */
252
+ async rescan() {
253
+ return this.scanAndRegister()
254
+ }
255
+
256
+ /**
257
+ * Destroy script manager
258
+ */
259
+ destroy() {
260
+ this.scanner.disconnect()
261
+ this.registry.clear()
262
+ this.loader.clear()
263
+ this.isInitialized = false
264
+ this._debug('ScriptManager destroyed')
265
+ }
266
+
267
+ // --- Internal helpers ---
268
+
269
+ _emitEvent(eventName, detail) {
270
+ if (typeof window !== 'undefined') {
271
+ window.dispatchEvent(new CustomEvent(`cookie-consent:${eventName}`, { detail }))
272
+ }
273
+ }
274
+
275
+ _debug(...args) {
276
+ // Debug disabled
277
+ }
278
+ }