@okeyamy/lua 5.0.4

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,1566 @@
1
+ /**
2
+ lua - A client side A/B tester
3
+ @version v5.0.3
4
+ @link https://github.com/OkeyAmy/Lua-Dynamic-Website-
5
+ @author Okey Amy <amaobiokeoma@gmail.com>
6
+ @license MIT
7
+ **/
8
+ var rand = function rand(min, max) {
9
+ return Math.random() * (max - min) + min;
10
+ };
11
+
12
+ // choose a random value with the specified weights
13
+ var chooseWeightedItem = function chooseWeightedItem(names, weights) {
14
+ if (names.length !== weights.length) throw new Error('names and weights must have equal length!');
15
+ var sum = weights.reduce(function (a, b) {
16
+ return a + b;
17
+ }, 0);
18
+ var limit = 0;
19
+ var n = rand(0, sum);
20
+ for (var i = 0; i < names.length; i++) {
21
+ limit += weights[i];
22
+ if (n <= limit) return names[i];
23
+ }
24
+ // by default, return the last weight
25
+ return names[names.length - 1];
26
+ };
27
+
28
+ // get the default bucket,
29
+ // which is either the default/winner,
30
+ // otherwise whichever is returned first
31
+ var getDefaultBucket = function getDefaultBucket(buckets) {
32
+ var defaultBuckets = Object.keys(buckets).filter(function (name) {
33
+ var x = buckets[name];
34
+ return x["default"] || x.winner;
35
+ });
36
+ return defaultBuckets[0] || Object.keys(buckets)[0];
37
+ };
38
+ var validateStore = function validateStore(store) {
39
+ if (!store) throw new Error('You must supply a store!');
40
+ if (typeof store.get !== 'function') throw new Error('The store must implement .get()');
41
+ if (typeof store.set !== 'function') throw new Error('The store must implement .set()');
42
+ if (typeof store.isSupported !== 'function') throw new Error('The store must implement .isSupported()');
43
+ if (!store.isSupported()) throw new Error('The store is not supported.');
44
+ };
45
+ var getRandomAssignment = function getRandomAssignment(test) {
46
+ var names = Object.keys(test.buckets);
47
+ var weights = [];
48
+ names.forEach(function (innerBucketName) {
49
+ var weight = test.buckets[innerBucketName].weight;
50
+ if (weight == null) weight = 1;
51
+ weights.push(weight);
52
+ });
53
+ return chooseWeightedItem(names, weights);
54
+ };
55
+
56
+ // UTM functions are now on window.LuaUTM (IIFE pattern, no import needed)
57
+ // utm.js must be loaded before lua.js to populate window.LuaUTM
58
+ var Lua = /*#__PURE__*/function () {
59
+ function Lua(options) {
60
+ if (options === void 0) {
61
+ options = {};
62
+ }
63
+ Object.assign(this, {
64
+ storageKey: 'ab-tests',
65
+ root: typeof document !== 'undefined' ? document.body : null
66
+ }, options);
67
+ validateStore(this.store);
68
+ this.previousAssignments = {};
69
+ try {
70
+ // assert that the data is a JSON string
71
+ // that represents a JSON object
72
+ // saw a bug where it was, for some reason, stored as `null`
73
+ var data = this.store.get(this.storageKey);
74
+ if (typeof data === 'string' && data[0] === '{') {
75
+ this.previousAssignments = JSON.parse(data);
76
+ }
77
+ } catch (_) {
78
+ // ignore
79
+ }
80
+ this.userAssignments = {};
81
+ this.persistedUserAssignments = {};
82
+ this.providedTests = [];
83
+ }
84
+ var _proto = Lua.prototype;
85
+ _proto.define = function define(tests) {
86
+ var _this = this;
87
+ var normalizedData = tests;
88
+ if (!Array.isArray(tests)) normalizedData = [tests];
89
+ normalizedData.forEach(function (test) {
90
+ if (!test.name) throw new Error('Tests must have a name');
91
+ if (!test.buckets) throw new Error('Tests must have buckets');
92
+ if (!Object.keys(test.buckets)) throw new Error('Tests must have buckets');
93
+ _this.providedTests.push(test);
94
+ });
95
+ };
96
+ _proto.definitions = function definitions() {
97
+ return this.providedTests;
98
+ };
99
+ _proto.removeClasses = function removeClasses(testName, exceptClassName) {
100
+ try {
101
+ var root = this.root;
102
+ if (!root) return;
103
+
104
+ // classList does not support returning all classes
105
+ var currentClassNames = root.className.split(/\s+/g).map(function (x) {
106
+ return x.trim();
107
+ }).filter(Boolean);
108
+ currentClassNames.filter(function (x) {
109
+ return x.indexOf(testName + "--") === 0;
110
+ }).filter(function (className) {
111
+ return className !== exceptClassName;
112
+ }).forEach(function (className) {
113
+ return root.classList.remove(className);
114
+ });
115
+ } catch (_) {
116
+ // Ignore
117
+ }
118
+ };
119
+ _proto.applyClasses = function applyClasses() {
120
+ var _this2 = this;
121
+ try {
122
+ var userAssignments = this.userAssignments,
123
+ root = this.root;
124
+ if (!root) return;
125
+ Object.keys(userAssignments).forEach(function (testName) {
126
+ var bucket = userAssignments[testName];
127
+ var className = bucket ? testName + "--" + bucket : null;
128
+ // remove all classes related to this bucket
129
+ _this2.removeClasses(testName, className);
130
+
131
+ // only assign a class is the test is assigned to a bucket
132
+ // this removes then adds a class, which is not ideal but is clean
133
+ if (className) root.classList.add(className);
134
+ });
135
+ } catch (_) {
136
+ // Ignore
137
+ }
138
+ };
139
+ _proto.assignAll = function assignAll() {
140
+ var previousAssignments = this.previousAssignments,
141
+ userAssignments = this.userAssignments,
142
+ persistedUserAssignments = this.persistedUserAssignments;
143
+ this.providedTests.forEach(function (test) {
144
+ // winners take precedence
145
+ {
146
+ var winner = Object.keys(test.buckets).filter(function (name) {
147
+ return test.buckets[name].winner;
148
+ })[0];
149
+ if (winner) {
150
+ userAssignments[test.name] = winner;
151
+ return;
152
+ }
153
+ }
154
+
155
+ // already assigned, probably because someone
156
+ // called `.assignAll()` twice.
157
+ if (userAssignments[test.name]) return;
158
+ {
159
+ // previously assigned, so we continue to persist it
160
+ var bucket = previousAssignments[test.name];
161
+ if (bucket && test.buckets[bucket]) {
162
+ var assignment = previousAssignments[test.name];
163
+ persistedUserAssignments[test.name] = assignment;
164
+ userAssignments[test.name] = assignment;
165
+ test.active = true;
166
+ return;
167
+ }
168
+ }
169
+
170
+ // inactive tests should be set to default
171
+ if (test.active === false) {
172
+ userAssignments[test.name] = getDefaultBucket(test.buckets);
173
+ return;
174
+ }
175
+
176
+ // randomly assign
177
+ {
178
+ var _assignment = getRandomAssignment(test);
179
+ persistedUserAssignments[test.name] = _assignment;
180
+ userAssignments[test.name] = _assignment;
181
+ }
182
+ });
183
+ this.persist();
184
+ this.applyClasses();
185
+ };
186
+ _proto.assign = function assign(testName, bucketName) {
187
+ if (!testName) return this.assignAll();
188
+ var test = this.providedTests.filter(function (x) {
189
+ return x.name === testName;
190
+ })[0];
191
+ if (bucketName === null || !test) {
192
+ delete this.userAssignments[testName];
193
+ delete this.persistedUserAssignments[testName];
194
+ this.persist();
195
+ this.removeClasses(testName);
196
+ return;
197
+ }
198
+ var assignment = bucketName || getRandomAssignment(test);
199
+ this.userAssignments[testName] = assignment;
200
+ this.persistedUserAssignments[testName] = assignment;
201
+ test.active = true;
202
+ this.persist();
203
+ this.applyClasses();
204
+ };
205
+ _proto.extendAssignments = function extendAssignments(assignments) {
206
+ return assignments;
207
+ };
208
+ _proto.assignments = function assignments() {
209
+ return this.extendAssignments(this.userAssignments);
210
+ };
211
+ _proto.persist = function persist() {
212
+ this.store.set(this.storageKey, JSON.stringify(this.persistedUserAssignments));
213
+ }
214
+
215
+ /**
216
+ * Get UTM context for the current page
217
+ * Uses window.LuaUTM global (populated by utm.js IIFE)
218
+ * @returns {Object} - Context object with UTM params, referrer, user agent, and intent
219
+ */;
220
+ _proto.getUTMContext = function getUTMContext() {
221
+ try {
222
+ var _root = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : {};
223
+ if (_root.LuaUTM && typeof _root.LuaUTM.getContext === 'function') {
224
+ return _root.LuaUTM.getContext();
225
+ }
226
+ return {
227
+ utm: {},
228
+ referrer: {},
229
+ userAgent: {},
230
+ primaryIntent: 'default',
231
+ hasUTM: false
232
+ };
233
+ } catch (_) {
234
+ return {
235
+ utm: {},
236
+ referrer: {},
237
+ userAgent: {},
238
+ primaryIntent: 'default',
239
+ hasUTM: false
240
+ };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Get bucket based on UTM context
246
+ * Maps UTM intent to test buckets
247
+ * @param {Object} test - Test definition with buckets
248
+ * @param {Object} context - UTM context
249
+ * @returns {string|null} - Bucket name or null if no match
250
+ */;
251
+ _proto.getUTMBasedBucket = function getUTMBasedBucket(test, context) {
252
+ if (!context || !context.hasUTM) return null;
253
+
254
+ // Check if test has UTM rules defined
255
+ var utmRules = test.utmRules || {};
256
+
257
+ // Priority 1: Match by utm_campaign
258
+ if (context.utm.utm_campaign) {
259
+ var campaignRule = utmRules[context.utm.utm_campaign];
260
+ if (campaignRule && test.buckets[campaignRule]) {
261
+ return campaignRule;
262
+ }
263
+ }
264
+
265
+ // Priority 2: Match by utm_source
266
+ if (context.utm.utm_source) {
267
+ var sourceRule = utmRules[context.utm.utm_source];
268
+ if (sourceRule && test.buckets[sourceRule]) {
269
+ return sourceRule;
270
+ }
271
+ }
272
+
273
+ // Priority 3: Match by inferred intent
274
+ var intent = context.primaryIntent;
275
+ if (intent && test.buckets[intent]) {
276
+ return intent;
277
+ }
278
+
279
+ // Priority 4: Check for intent-mapped buckets
280
+ var intentMapping = test.intentMapping || {};
281
+ if (intent && intentMapping[intent] && test.buckets[intentMapping[intent]]) {
282
+ return intentMapping[intent];
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Assign with UTM-aware personalization
289
+ * Falls back to random A/B if no UTM match
290
+ * @param {string} [testName] - Optional specific test name
291
+ * @param {Object} [options] - Options including forceUTM, context
292
+ * @returns {Object} - { assignment, source: 'utm'|'random'|'persisted' }
293
+ */;
294
+ _proto.assignWithUTM = function assignWithUTM(testName, options) {
295
+ if (options === void 0) {
296
+ options = {};
297
+ }
298
+ var context = options.context || this.getUTMContext();
299
+
300
+ // If no test name, assign all with UTM awareness
301
+ if (!testName) {
302
+ return this.assignAllWithUTM(context);
303
+ }
304
+ var test = this.providedTests.filter(function (x) {
305
+ return x.name === testName;
306
+ })[0];
307
+ if (!test) {
308
+ return {
309
+ assignment: null,
310
+ source: 'none'
311
+ };
312
+ }
313
+
314
+ // Check for winner first (takes precedence)
315
+ var winner = Object.keys(test.buckets).filter(function (name) {
316
+ return test.buckets[name].winner;
317
+ })[0];
318
+ if (winner) {
319
+ this.userAssignments[testName] = winner;
320
+ this.persist();
321
+ this.applyClasses();
322
+ return {
323
+ assignment: winner,
324
+ source: 'winner'
325
+ };
326
+ }
327
+
328
+ // Check previous assignment
329
+ var previousBucket = this.previousAssignments[testName];
330
+ if (previousBucket && test.buckets[previousBucket]) {
331
+ this.userAssignments[testName] = previousBucket;
332
+ this.persistedUserAssignments[testName] = previousBucket;
333
+ test.active = true;
334
+ this.persist();
335
+ this.applyClasses();
336
+ return {
337
+ assignment: previousBucket,
338
+ source: 'persisted'
339
+ };
340
+ }
341
+
342
+ // Try UTM-based assignment
343
+ var utmBucket = this.getUTMBasedBucket(test, context);
344
+ if (utmBucket) {
345
+ this.userAssignments[testName] = utmBucket;
346
+ this.persistedUserAssignments[testName] = utmBucket;
347
+ test.active = true;
348
+ this.persist();
349
+ this.applyClasses();
350
+ return {
351
+ assignment: utmBucket,
352
+ source: 'utm',
353
+ intent: context.primaryIntent
354
+ };
355
+ }
356
+
357
+ // Fallback to random assignment
358
+ var assignment = getRandomAssignment(test);
359
+ this.userAssignments[testName] = assignment;
360
+ this.persistedUserAssignments[testName] = assignment;
361
+ test.active = true;
362
+ this.persist();
363
+ this.applyClasses();
364
+ return {
365
+ assignment: assignment,
366
+ source: 'random'
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Assign all tests with UTM awareness
372
+ * @param {Object} context - UTM context
373
+ * @returns {Object} - Map of test names to assignment results
374
+ */;
375
+ _proto.assignAllWithUTM = function assignAllWithUTM(context) {
376
+ var _this3 = this;
377
+ context = context || this.getUTMContext();
378
+ var results = {};
379
+ this.providedTests.forEach(function (test) {
380
+ var result = _this3.assignWithUTM(test.name, {
381
+ context: context
382
+ });
383
+ results[test.name] = result;
384
+ });
385
+ return results;
386
+ };
387
+ return Lua;
388
+ }();
389
+
390
+ // NOTE: use a module
391
+ var browserCookie = (function () {
392
+ return {
393
+ type: 'browserCookie',
394
+ /*eslint-disable */
395
+ get: function get(key) {
396
+ return decodeURIComponent(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + encodeURIComponent(key).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null;
397
+ },
398
+ set: function set(key, val) {
399
+ var expirationDate = new Date('12/31/9999').toUTCString();
400
+ document.cookie = encodeURIComponent(key) + "=" + encodeURIComponent(val) + "; expires=" + expirationDate + "; path=/";
401
+ },
402
+ /* eslint-enable */
403
+ isSupported: function isSupported() {
404
+ return typeof document !== 'undefined';
405
+ }
406
+ };
407
+ });
408
+
409
+ var local = (function () {
410
+ return {
411
+ type: 'local',
412
+ get: function get(key) {
413
+ return localStorage.getItem(key);
414
+ },
415
+ set: function set(key, val) {
416
+ return localStorage.setItem(key, val);
417
+ },
418
+ isSupported: function isSupported() {
419
+ if (typeof localStorage !== 'undefined') return true;
420
+ var uid = new Date();
421
+ try {
422
+ localStorage.setItem(uid, uid);
423
+ localStorage.removeItem(uid);
424
+ return true;
425
+ } catch (e) {
426
+ return false;
427
+ }
428
+ }
429
+ };
430
+ });
431
+
432
+ var memory = (function () {
433
+ var store = Object.create(null);
434
+ return {
435
+ type: 'memory',
436
+ get: function get(key) {
437
+ return store[key];
438
+ },
439
+ set: function set(key, val) {
440
+ store[key] = val;
441
+ },
442
+ isSupported: function isSupported() {
443
+ return true;
444
+ }
445
+ };
446
+ });
447
+
448
+ /**
449
+ * UTM Parameter Extraction & Context Detection
450
+ * Uses native URLSearchParams API for extracting UTM parameters
451
+ * and document.referrer/navigator.userAgent for context inference
452
+ *
453
+ * No ES6 imports - self-contained IIFE that registers on window.LuaUTM
454
+ * Can be loaded standalone via <script> tag or bundled by Rollup
455
+ */
456
+ (function (root) {
457
+
458
+ // Default timeout for async operations (1 second max as recommended)
459
+ var UTM_TIMEOUT_MS = 1000;
460
+
461
+ // Allowed UTM parameter names
462
+ var UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
463
+
464
+ // Referrer type patterns
465
+ var REFERRER_PATTERNS = {
466
+ google: /google\./i,
467
+ bing: /bing\./i,
468
+ yahoo: /yahoo\./i,
469
+ duckduckgo: /duckduckgo\./i,
470
+ facebook: /facebook\.com|fb\.com/i,
471
+ twitter: /twitter\.com|t\.co|x\.com/i,
472
+ instagram: /instagram\.com/i,
473
+ linkedin: /linkedin\.com/i,
474
+ pinterest: /pinterest\./i,
475
+ tiktok: /tiktok\.com/i,
476
+ youtube: /youtube\.com|youtu\.be/i,
477
+ reddit: /reddit\.com/i
478
+ };
479
+
480
+ // Referrer category mapping
481
+ var REFERRER_CATEGORIES = {
482
+ search: ['google', 'bing', 'yahoo', 'duckduckgo'],
483
+ social: ['facebook', 'twitter', 'instagram', 'linkedin', 'pinterest', 'tiktok', 'youtube', 'reddit']
484
+ };
485
+
486
+ /**
487
+ * Safely extracts UTM parameters from URL using native URLSearchParams API
488
+ * @param {string} [url] - URL to parse (defaults to window.location.search)
489
+ * @returns {Object} - Object containing UTM parameters
490
+ */
491
+ function extractUTMParams(url) {
492
+ var result = {};
493
+ try {
494
+ var searchString = url || (typeof window !== 'undefined' ? window.location.search : '');
495
+ if (!searchString) {
496
+ return result;
497
+ }
498
+
499
+ // Use native URLSearchParams API
500
+ var params = new URLSearchParams(searchString);
501
+ UTM_PARAMS.forEach(function (param) {
502
+ var value = params.get(param);
503
+ if (value) {
504
+ // Sanitize: only allow alphanumeric, dashes, underscores
505
+ result[param] = sanitizeParam(value);
506
+ }
507
+ });
508
+ } catch (e) {
509
+ // Fallback: return empty object on any error
510
+ console.warn('[Lua UTM] Error extracting UTM params:', e);
511
+ }
512
+ return result;
513
+ }
514
+
515
+ /**
516
+ * Sanitize parameter value to prevent XSS
517
+ * Only allows alphanumeric, dashes, underscores, and spaces
518
+ * @param {string} value - Raw parameter value
519
+ * @returns {string} - Sanitized value
520
+ */
521
+ function sanitizeParam(value) {
522
+ if (typeof value !== 'string') return '';
523
+ // Remove any HTML tags and special characters
524
+ return value.replace(/<[^>]*>/g, '') // Remove HTML tags
525
+ .replace(/[^\w\s\-_.]/g, '') // Only allow safe characters
526
+ .substring(0, 100) // Limit length
527
+ .trim();
528
+ }
529
+
530
+ /**
531
+ * Detect referrer type from document.referrer
532
+ * @returns {Object} - { source: string, category: 'search'|'social'|'email'|'direct'|'other' }
533
+ */
534
+ function detectReferrer() {
535
+ var result = {
536
+ source: 'direct',
537
+ category: 'direct',
538
+ url: ''
539
+ };
540
+ try {
541
+ if (typeof document === 'undefined' || !document.referrer) {
542
+ return result;
543
+ }
544
+ result.url = document.referrer;
545
+
546
+ // Check for email patterns in referrer
547
+ if (/mail\.|email\.|newsletter/i.test(document.referrer)) {
548
+ result.source = 'email';
549
+ result.category = 'email';
550
+ return result;
551
+ }
552
+
553
+ // Check against known patterns
554
+ for (var source in REFERRER_PATTERNS) {
555
+ if (REFERRER_PATTERNS[source].test(document.referrer)) {
556
+ result.source = source;
557
+
558
+ // Determine category
559
+ for (var category in REFERRER_CATEGORIES) {
560
+ if (REFERRER_CATEGORIES[category].indexOf(source) !== -1) {
561
+ result.category = category;
562
+ break;
563
+ }
564
+ }
565
+ return result;
566
+ }
567
+ }
568
+
569
+ // Unknown external referrer
570
+ result.source = 'external';
571
+ result.category = 'other';
572
+ } catch (e) {
573
+ console.warn('[Lua UTM] Error detecting referrer:', e);
574
+ }
575
+ return result;
576
+ }
577
+
578
+ /**
579
+ * Get user agent info (for device/browser detection)
580
+ * @returns {Object} - User agent metadata
581
+ */
582
+ function getUserAgentInfo() {
583
+ var result = {
584
+ raw: '',
585
+ isMobile: false,
586
+ isTablet: false,
587
+ isDesktop: true
588
+ };
589
+ try {
590
+ if (typeof navigator === 'undefined' || !navigator.userAgent) {
591
+ return result;
592
+ }
593
+ result.raw = navigator.userAgent;
594
+
595
+ // Mobile detection
596
+ result.isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
597
+ result.isTablet = /iPad|Android(?!.*Mobile)/i.test(navigator.userAgent);
598
+ result.isDesktop = !result.isMobile && !result.isTablet;
599
+ } catch (e) {
600
+ console.warn('[Lua UTM] Error getting user agent:', e);
601
+ }
602
+ return result;
603
+ }
604
+
605
+ /**
606
+ * Get full personalization context
607
+ * Combines UTM params, referrer info, and user agent
608
+ * @param {Object} [options] - Configuration options
609
+ * @param {string} [options.url] - Custom URL to parse
610
+ * @returns {Object} - Complete context object
611
+ */
612
+ function getContext(options) {
613
+ options = options || {};
614
+ var context = {
615
+ utm: extractUTMParams(options.url),
616
+ referrer: detectReferrer(),
617
+ userAgent: getUserAgentInfo(),
618
+ timestamp: Date.now(),
619
+ hasUTM: false,
620
+ primaryIntent: 'unknown'
621
+ };
622
+
623
+ // Determine if we have UTM data
624
+ context.hasUTM = Object.keys(context.utm).length > 0;
625
+
626
+ // Infer primary intent
627
+ context.primaryIntent = inferIntent(context);
628
+ return context;
629
+ }
630
+
631
+ /**
632
+ * Infer user intent from context
633
+ * Priority: UTM campaign > UTM source > Referrer category
634
+ * @param {Object} context - Full context object
635
+ * @returns {string} - Inferred intent key
636
+ */
637
+ function inferIntent(context) {
638
+ // Priority 1: UTM campaign tells us the specific intent
639
+ if (context.utm.utm_campaign) {
640
+ var campaign = context.utm.utm_campaign.toLowerCase();
641
+ if (/sale|discount|offer|promo/i.test(campaign)) return 'price-focused';
642
+ if (/gaming|game|esport/i.test(campaign)) return 'gaming';
643
+ if (/work|office|professional|productivity/i.test(campaign)) return 'professional';
644
+ if (/creative|design|art|studio/i.test(campaign)) return 'creative';
645
+ if (/brand|story|about/i.test(campaign)) return 'brand-story';
646
+ }
647
+
648
+ // Priority 2: UTM source can indicate intent
649
+ if (context.utm.utm_source) {
650
+ var source = context.utm.utm_source.toLowerCase();
651
+ if (/google|bing|yahoo/i.test(source)) return 'search-optimized';
652
+ if (/facebook|instagram|tiktok/i.test(source)) return 'social-visual';
653
+ if (/twitter|x$/i.test(source)) return 'social-brief';
654
+ if (/email|newsletter/i.test(source)) return 'returning-user';
655
+ if (/youtube/i.test(source)) return 'video-engaged';
656
+ }
657
+
658
+ // Priority 3: Referrer category
659
+ if (context.referrer.category === 'search') return 'search-optimized';
660
+ if (context.referrer.category === 'social') return 'social-visual';
661
+ if (context.referrer.category === 'email') return 'returning-user';
662
+
663
+ // Default
664
+ return 'default';
665
+ }
666
+
667
+ /**
668
+ * Get context with timeout fallback
669
+ * Returns default context if operation takes too long
670
+ * @param {Object} [options] - Configuration options
671
+ * @param {number} [options.timeout] - Timeout in ms (default: 1000)
672
+ * @returns {Promise<Object>} - Context object
673
+ */
674
+ function getContextAsync(options) {
675
+ options = options || {};
676
+ var timeout = options.timeout || UTM_TIMEOUT_MS;
677
+ return new Promise(function (resolve) {
678
+ var timer = setTimeout(function () {
679
+ // Timeout: return default context
680
+ resolve({
681
+ utm: {},
682
+ referrer: {
683
+ source: 'direct',
684
+ category: 'direct',
685
+ url: ''
686
+ },
687
+ userAgent: {
688
+ raw: '',
689
+ isMobile: false,
690
+ isTablet: false,
691
+ isDesktop: true
692
+ },
693
+ timestamp: Date.now(),
694
+ hasUTM: false,
695
+ primaryIntent: 'default',
696
+ timedOut: true
697
+ });
698
+ }, timeout);
699
+ try {
700
+ var context = getContext(options);
701
+ clearTimeout(timer);
702
+ context.timedOut = false;
703
+ resolve(context);
704
+ } catch (e) {
705
+ clearTimeout(timer);
706
+ resolve({
707
+ utm: {},
708
+ referrer: {
709
+ source: 'direct',
710
+ category: 'direct',
711
+ url: ''
712
+ },
713
+ userAgent: {
714
+ raw: '',
715
+ isMobile: false,
716
+ isTablet: false,
717
+ isDesktop: true
718
+ },
719
+ timestamp: Date.now(),
720
+ hasUTM: false,
721
+ primaryIntent: 'default',
722
+ error: e.message
723
+ });
724
+ }
725
+ });
726
+ }
727
+
728
+ // --- Public API ---
729
+ // Register on the global root (window in browser)
730
+ var LuaUTM = {
731
+ extractUTMParams: extractUTMParams,
732
+ sanitizeParam: sanitizeParam,
733
+ detectReferrer: detectReferrer,
734
+ getUserAgentInfo: getUserAgentInfo,
735
+ getContext: getContext,
736
+ getContextAsync: getContextAsync,
737
+ inferIntent: inferIntent,
738
+ UTM_PARAMS: UTM_PARAMS,
739
+ UTM_TIMEOUT_MS: UTM_TIMEOUT_MS,
740
+ REFERRER_PATTERNS: REFERRER_PATTERNS,
741
+ REFERRER_CATEGORIES: REFERRER_CATEGORIES
742
+ };
743
+
744
+ // Expose globally
745
+ root.LuaUTM = LuaUTM;
746
+ })(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : undefined);
747
+
748
+ /**
749
+ * DOM Personalization Engine
750
+ * Handles content injection with data-personalize attributes
751
+ * Uses textContent for text, DOMPurify-style sanitized HTML for rich content
752
+ *
753
+ * No ES6 imports - self-contained IIFE that registers on window.LuaPersonalize
754
+ * Depends on window.LuaUTM (from utm.js) for context extraction
755
+ * Falls back to random A/B test when no UTM params are present
756
+ */
757
+ (function (root) {
758
+
759
+ // ===================================================================
760
+ // DOMPurify-style HTML Sanitizer (inline, OWASP-recommended approach)
761
+ // Provides safe HTML injection without external dependencies
762
+ // ===================================================================
763
+
764
+ /**
765
+ * Inline DOMPurify-style sanitizer
766
+ * Uses the browser's DOMParser to safely parse and sanitize HTML
767
+ * Falls back to regex-based sanitization if DOMParser unavailable
768
+ */
769
+ var Sanitizer = function () {
770
+ // Allowed HTML tags (safe for content injection)
771
+ var ALLOWED_TAGS = {
772
+ 'p': true,
773
+ 'span': true,
774
+ 'strong': true,
775
+ 'em': true,
776
+ 'b': true,
777
+ 'i': true,
778
+ 'br': true,
779
+ 'a': true,
780
+ 'img': true,
781
+ 'h1': true,
782
+ 'h2': true,
783
+ 'h3': true,
784
+ 'h4': true,
785
+ 'h5': true,
786
+ 'h6': true,
787
+ 'div': true,
788
+ 'section': true,
789
+ 'ul': true,
790
+ 'ol': true,
791
+ 'li': true,
792
+ 'blockquote': true,
793
+ 'figure': true,
794
+ 'figcaption': true
795
+ };
796
+
797
+ // Allowed HTML attributes (safe subset)
798
+ var ALLOWED_ATTRS = {
799
+ 'href': true,
800
+ 'src': true,
801
+ 'alt': true,
802
+ 'class': true,
803
+ 'id': true,
804
+ 'title': true,
805
+ 'target': true,
806
+ 'rel': true,
807
+ 'width': true,
808
+ 'height': true,
809
+ 'loading': true
810
+ };
811
+
812
+ // Dangerous URI schemes
813
+ var DANGEROUS_URI = /^(javascript|vbscript|data):/i;
814
+
815
+ // Event handler pattern (onclick, onerror, onload, etc.)
816
+ var EVENT_HANDLER = /^on/i;
817
+
818
+ /**
819
+ * Check if DOMParser is available (modern browsers)
820
+ * @returns {boolean}
821
+ */
822
+ function hasDOMParser() {
823
+ try {
824
+ return typeof DOMParser !== 'undefined' && new DOMParser();
825
+ } catch (e) {
826
+ return false;
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Sanitize HTML using DOMParser (preferred, secure method)
832
+ * Parses HTML into a DOM tree, walks nodes, and rebuilds safe HTML
833
+ * @param {string} dirty - Untrusted HTML string
834
+ * @returns {string} - Sanitized HTML string
835
+ */
836
+ function sanitizeWithDOMParser(dirty) {
837
+ if (typeof dirty !== 'string' || !dirty.trim()) return '';
838
+ try {
839
+ var parser = new DOMParser();
840
+ var doc = parser.parseFromString(dirty, 'text/html');
841
+ var body = doc.body;
842
+ if (!body) return '';
843
+ return walkAndClean(body);
844
+ } catch (e) {
845
+ console.warn('[Lua Sanitizer] DOMParser failed, using fallback:', e);
846
+ return sanitizeWithRegex(dirty);
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Recursively walk DOM nodes and build clean HTML
852
+ * @param {Node} node - DOM node to process
853
+ * @returns {string} - Cleaned HTML string
854
+ */
855
+ function walkAndClean(node) {
856
+ var output = '';
857
+ for (var i = 0; i < node.childNodes.length; i++) {
858
+ var child = node.childNodes[i];
859
+
860
+ // Text node - safe to include
861
+ if (child.nodeType === 3) {
862
+ output += escapeText(child.textContent);
863
+ continue;
864
+ }
865
+
866
+ // Element node
867
+ if (child.nodeType === 1) {
868
+ var tagName = child.tagName.toLowerCase();
869
+
870
+ // Skip disallowed tags entirely (including children)
871
+ if (tagName === 'script' || tagName === 'style' || tagName === 'iframe' || tagName === 'object' || tagName === 'embed' || tagName === 'form' || tagName === 'input' || tagName === 'textarea') {
872
+ continue;
873
+ }
874
+
875
+ // If tag is allowed, include it with filtered attributes
876
+ if (ALLOWED_TAGS[tagName]) {
877
+ output += '<' + tagName;
878
+ output += cleanAttributes(child);
879
+ output += '>';
880
+
881
+ // Self-closing tags
882
+ if (tagName === 'br' || tagName === 'img') {
883
+ continue;
884
+ }
885
+
886
+ // Recurse into children
887
+ output += walkAndClean(child);
888
+ output += '</' + tagName + '>';
889
+ } else {
890
+ // Tag not allowed - include children only (strip the tag)
891
+ output += walkAndClean(child);
892
+ }
893
+ }
894
+ }
895
+ return output;
896
+ }
897
+
898
+ /**
899
+ * Filter element attributes to only allowed ones
900
+ * @param {Element} element - DOM element
901
+ * @returns {string} - Attribute string
902
+ */
903
+ function cleanAttributes(element) {
904
+ var attrStr = '';
905
+ var attrs = element.attributes;
906
+ for (var i = 0; i < attrs.length; i++) {
907
+ var attr = attrs[i];
908
+ var name = attr.name.toLowerCase();
909
+ var value = attr.value;
910
+
911
+ // Skip event handlers (onclick, onerror, etc.)
912
+ if (EVENT_HANDLER.test(name)) continue;
913
+
914
+ // Skip disallowed attributes
915
+ if (!ALLOWED_ATTRS[name]) continue;
916
+
917
+ // Check URI safety for href/src
918
+ if ((name === 'href' || name === 'src') && DANGEROUS_URI.test(value.trim())) {
919
+ continue;
920
+ }
921
+
922
+ // Add rel="noopener noreferrer" for external links
923
+ if (name === 'target' && value === '_blank') {
924
+ attrStr += ' target="_blank" rel="noopener noreferrer"';
925
+ continue;
926
+ }
927
+ attrStr += ' ' + name + '="' + escapeAttr(value) + '"';
928
+ }
929
+ return attrStr;
930
+ }
931
+
932
+ /**
933
+ * Escape text content for safe HTML inclusion
934
+ * @param {string} text - Raw text
935
+ * @returns {string} - Escaped text
936
+ */
937
+ function escapeText(text) {
938
+ if (!text) return '';
939
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
940
+ }
941
+
942
+ /**
943
+ * Escape attribute value for safe HTML inclusion
944
+ * @param {string} value - Raw attribute value
945
+ * @returns {string} - Escaped attribute value
946
+ */
947
+ function escapeAttr(value) {
948
+ if (!value) return '';
949
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
950
+ }
951
+
952
+ /**
953
+ * Fallback regex-based sanitizer for environments without DOMParser
954
+ * @param {string} html - Raw HTML string
955
+ * @returns {string} - Sanitized HTML
956
+ */
957
+ function sanitizeWithRegex(html) {
958
+ if (typeof html !== 'string') return '';
959
+ var DANGEROUS_PATTERNS = [/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, /<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, /<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, /<embed\b[^>]*>/gi, /<link\b[^>]*>/gi, /<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, /<form\b[^<]*(?:(?!<\/form>)<[^<]*)*<\/form>/gi, /javascript:/gi, /vbscript:/gi, /data:/gi, /on\w+\s*=/gi];
960
+ var sanitized = html;
961
+ DANGEROUS_PATTERNS.forEach(function (pattern) {
962
+ sanitized = sanitized.replace(pattern, '');
963
+ });
964
+
965
+ // Remove disallowed tags but keep their text content
966
+ sanitized = sanitized.replace(/<\/?(\w+)([^>]*)>/g, function (match, tagName, attrs) {
967
+ var tag = tagName.toLowerCase();
968
+ if (!ALLOWED_TAGS[tag]) return '';
969
+
970
+ // For closing tags, just return the closing tag
971
+ if (match.charAt(1) === '/') return '</' + tag + '>';
972
+
973
+ // Filter attributes
974
+ var cleanAttrs = '';
975
+ var attrRegex = /(\w+)=['"]([^'"]*)['"]/g;
976
+ var attrMatch;
977
+ while ((attrMatch = attrRegex.exec(attrs)) !== null) {
978
+ var attrName = attrMatch[1].toLowerCase();
979
+ if (ALLOWED_ATTRS[attrName] && !EVENT_HANDLER.test(attrName)) {
980
+ var val = attrMatch[2];
981
+ if ((attrName === 'href' || attrName === 'src') && DANGEROUS_URI.test(val)) {
982
+ continue;
983
+ }
984
+ cleanAttrs += ' ' + attrName + '="' + val + '"';
985
+ }
986
+ }
987
+ return '<' + tag + cleanAttrs + '>';
988
+ });
989
+ return sanitized;
990
+ }
991
+
992
+ // Public sanitizer API
993
+ return {
994
+ /**
995
+ * Sanitize HTML string (main entry point)
996
+ * Uses DOMParser when available, regex fallback otherwise
997
+ * @param {string} dirty - Untrusted HTML
998
+ * @returns {string} - Sanitized HTML
999
+ */
1000
+ sanitize: function sanitize(dirty) {
1001
+ if (typeof dirty !== 'string') return '';
1002
+ if (!dirty.trim()) return '';
1003
+ if (hasDOMParser()) {
1004
+ return sanitizeWithDOMParser(dirty);
1005
+ }
1006
+ return sanitizeWithRegex(dirty);
1007
+ },
1008
+ escapeText: escapeText,
1009
+ escapeAttr: escapeAttr
1010
+ };
1011
+ }();
1012
+
1013
+ // ===================================================================
1014
+ // Templates & Assets
1015
+ // ===================================================================
1016
+ // NOTE: Templates are NOT provided by this package.
1017
+ // Users must provide their own templates via options.templates
1018
+ // This keeps the package modular and allows users full control
1019
+ // over their content, assets, and personalization strategy.
1020
+
1021
+ // ===================================================================
1022
+ // DOM Interaction (safe methods - never raw innerHTML)
1023
+ // ===================================================================
1024
+
1025
+ /**
1026
+ * Safely set text content on an element (no HTML parsing)
1027
+ * @param {Element} element - DOM element
1028
+ * @param {string} text - Text to set
1029
+ */
1030
+ function safeSetText(element, text) {
1031
+ if (!element) return;
1032
+ element.textContent = text;
1033
+ }
1034
+
1035
+ /**
1036
+ * Safely set HTML content on an element (DOMPurify-sanitized)
1037
+ * @param {Element} element - DOM element
1038
+ * @param {string} html - HTML to set (will be sanitized)
1039
+ */
1040
+ function safeSetHTML(element, html) {
1041
+ if (!element) return;
1042
+ element.innerHTML = Sanitizer.sanitize(html);
1043
+ }
1044
+
1045
+ /**
1046
+ * Find all elements with data-personalize attribute
1047
+ * @param {string} [key] - Optional specific key to find
1048
+ * @param {Element} [searchRoot] - Root element to search from (default: document)
1049
+ * @returns {NodeList|Array} - Matching elements
1050
+ */
1051
+ function findPersonalizeElements(key, searchRoot) {
1052
+ searchRoot = searchRoot || (typeof document !== 'undefined' ? document : null);
1053
+ if (!searchRoot) return [];
1054
+ var selector = key ? '[data-personalize="' + key + '"]' : '[data-personalize]';
1055
+ return searchRoot.querySelectorAll(selector);
1056
+ }
1057
+
1058
+ /**
1059
+ * Get template for a given intent
1060
+ * Templates must be provided by the user via options.templates
1061
+ * Falls back to 'default' template if intent not found
1062
+ * @param {string} intent - Intent key
1063
+ * @param {Object} userTemplates - User-provided templates (required)
1064
+ * @returns {Object|null} - Template data or null if no templates provided
1065
+ */
1066
+ function getTemplate(intent, userTemplates) {
1067
+ if (!userTemplates || typeof userTemplates !== 'object') {
1068
+ console.warn('[Lua Personalize] No templates provided. Templates must be passed via options.templates');
1069
+ return null;
1070
+ }
1071
+
1072
+ // Try to get the intent template
1073
+ if (userTemplates[intent]) {
1074
+ return userTemplates[intent];
1075
+ }
1076
+
1077
+ // Fall back to 'default' template if available
1078
+ if (userTemplates['default']) {
1079
+ return userTemplates['default'];
1080
+ }
1081
+
1082
+ // If no default, return the first available template
1083
+ var firstKey = Object.keys(userTemplates)[0];
1084
+ if (firstKey) {
1085
+ console.warn('[Lua Personalize] Intent "' + intent + '" not found, using first available template:', firstKey);
1086
+ return userTemplates[firstKey];
1087
+ }
1088
+ return null;
1089
+ }
1090
+
1091
+ // ===================================================================
1092
+ // Random A/B Fallback (used when no UTM params are present)
1093
+ // ===================================================================
1094
+
1095
+ /**
1096
+ * Simple weighted random selection for A/B fallback
1097
+ * @param {Array} names - Array of bucket/template names
1098
+ * @param {Array} weights - Corresponding weights
1099
+ * @returns {string} - Selected name
1100
+ */
1101
+ function chooseWeightedRandom(names, weights) {
1102
+ if (names.length !== weights.length) return names[0];
1103
+ var sum = 0;
1104
+ var i;
1105
+ for (i = 0; i < weights.length; i++) {
1106
+ sum += weights[i];
1107
+ }
1108
+ var n = Math.random() * sum;
1109
+ var limit = 0;
1110
+ for (i = 0; i < names.length; i++) {
1111
+ limit += weights[i];
1112
+ if (n <= limit) return names[i];
1113
+ }
1114
+ return names[names.length - 1];
1115
+ }
1116
+
1117
+ /**
1118
+ * Get a random template key from user-provided templates
1119
+ * Used as fallback when no UTM/referrer context is available
1120
+ * @param {Object} userTemplates - User-provided templates (required)
1121
+ * @returns {string|null} - Random template intent key or null if no templates
1122
+ */
1123
+ function getRandomFallbackIntent(userTemplates) {
1124
+ if (!userTemplates || typeof userTemplates !== 'object') {
1125
+ return null;
1126
+ }
1127
+ var names = Object.keys(userTemplates);
1128
+ if (names.length === 0) {
1129
+ return null;
1130
+ }
1131
+ var weights = [];
1132
+ for (var i = 0; i < names.length; i++) {
1133
+ weights.push(1); // Equal weight by default
1134
+ }
1135
+ return chooseWeightedRandom(names, weights);
1136
+ }
1137
+
1138
+ // ===================================================================
1139
+ // Decision Engine
1140
+ // ===================================================================
1141
+
1142
+ /**
1143
+ * Personalization Decision Engine
1144
+ * Determines which content to show based on context
1145
+ * Priority: AI (if enabled) -> UTM params -> Referrer -> Random A/B fallback
1146
+ */
1147
+ var DecisionEngine = {
1148
+ /**
1149
+ * Standard (non-AI) decision logic
1150
+ * @param {Object} context - Context from LuaUTM.getContext()
1151
+ * @param {Object} [options] - Configuration options
1152
+ * @param {Object} [options.rules] - Custom matching rules
1153
+ * @param {Object} options.templates - User-provided templates (REQUIRED)
1154
+ * @param {boolean} [options.randomFallback] - Enable random A/B fallback (default: true)
1155
+ * @returns {Object} - { template, intent, source }
1156
+ */
1157
+ standardDecide: function standardDecide(context, options) {
1158
+ options = options || {};
1159
+ var customRules = options.rules || {};
1160
+ var userTemplates = options.templates;
1161
+ var enableRandomFallback = options.randomFallback !== false;
1162
+
1163
+ // Templates are required - warn if not provided
1164
+ if (!userTemplates || typeof userTemplates !== 'object' || Object.keys(userTemplates).length === 0) {
1165
+ console.warn('[Lua Personalize] No templates provided. Templates must be passed via options.templates');
1166
+ return {
1167
+ template: null,
1168
+ intent: 'default',
1169
+ source: 'error',
1170
+ context: context,
1171
+ error: 'No templates provided'
1172
+ };
1173
+ }
1174
+ var intent = context.primaryIntent;
1175
+ var source = 'default';
1176
+
1177
+ // Determine the source of the decision
1178
+ if (context.hasUTM) {
1179
+ source = 'utm';
1180
+ } else if (context.referrer && context.referrer.category !== 'direct') {
1181
+ source = 'referrer';
1182
+ }
1183
+
1184
+ // Check custom rules first (highest priority)
1185
+ for (var ruleKey in customRules) {
1186
+ var rule = customRules[ruleKey];
1187
+ if (typeof rule.match === 'function' && rule.match(context)) {
1188
+ intent = rule.intent || ruleKey;
1189
+ source = 'custom-rule';
1190
+ break;
1191
+ }
1192
+ }
1193
+
1194
+ // If intent is still 'default' and random fallback is enabled,
1195
+ // pick a random template for A/B testing
1196
+ if (intent === 'default' && source === 'default' && enableRandomFallback) {
1197
+ var randomIntent = getRandomFallbackIntent(userTemplates);
1198
+ if (randomIntent) {
1199
+ intent = randomIntent;
1200
+ source = 'random-ab';
1201
+ }
1202
+ }
1203
+
1204
+ // Record visit to history if LuaWeightedHistory is available
1205
+ if (root.LuaWeightedHistory && typeof root.LuaWeightedHistory.recordVisit === 'function') {
1206
+ root.LuaWeightedHistory.recordVisit({
1207
+ context: context,
1208
+ intent: intent,
1209
+ selectedVariant: intent,
1210
+ source: source,
1211
+ aiDecision: false
1212
+ });
1213
+ }
1214
+ return {
1215
+ template: getTemplate(intent, userTemplates),
1216
+ intent: intent,
1217
+ source: source,
1218
+ context: context
1219
+ };
1220
+ },
1221
+ /**
1222
+ * Main decide function - routes to AI or standard engine
1223
+ * @param {Object} context - Context from LuaUTM.getContext()
1224
+ * @param {Object} [options] - Configuration options
1225
+ * @param {boolean} [options.enableAI] - Enable AI-powered decisions
1226
+ * @param {Object} [options.aiConfig] - AI configuration
1227
+ * @returns {Object|Promise<Object>} - Decision result (Promise if AI enabled)
1228
+ */
1229
+ decide: function decide(context, options) {
1230
+ options = options || {};
1231
+
1232
+ // If AI is enabled and configured, try AI decision first
1233
+ if (options.enableAI && options.aiConfig && root.LuaAIPersonalize) {
1234
+ var self = this;
1235
+ var aiModule = root.LuaAIPersonalize;
1236
+ var readiness = aiModule.isReady(options.aiConfig);
1237
+ if (readiness.ready) {
1238
+ return aiModule.decide(context, options)["catch"](function (error) {
1239
+ // AI failed - fall back to standard engine
1240
+ var fallback = options.aiConfig.fallbackToStandard !== false;
1241
+ if (fallback) {
1242
+ console.warn('[Lua Personalize] AI failed, using standard engine:', error.message);
1243
+ return self.standardDecide(context, options);
1244
+ }
1245
+ throw error;
1246
+ });
1247
+ } else {
1248
+ console.warn('[Lua Personalize] AI not ready:', readiness.error, '- using standard engine');
1249
+ }
1250
+ }
1251
+
1252
+ // Standard decision (synchronous)
1253
+ return this.standardDecide(context, options);
1254
+ }
1255
+ };
1256
+
1257
+ // ===================================================================
1258
+ // Personalization Application
1259
+ // ===================================================================
1260
+
1261
+ // ===================================================================
1262
+ // DOM Application (extracted for reuse by both sync and async paths)
1263
+ // ===================================================================
1264
+
1265
+ /**
1266
+ * Apply a decision to the DOM
1267
+ * Injects content into elements with data-personalize attributes
1268
+ *
1269
+ * @param {Object} decision - Decision object { template, intent, source, context }
1270
+ * @param {Object} [options] - Configuration options
1271
+ * @param {boolean} [options.log] - Enable console logging
1272
+ * @returns {Object} - The decision (pass-through)
1273
+ */
1274
+ function applyDecisionToDOM(decision, options) {
1275
+ options = options || {};
1276
+ var template = decision.template;
1277
+ var context = decision.context || {};
1278
+ var log = options.log !== false;
1279
+ if (!template) {
1280
+ console.warn('[Lua Personalize] No template in decision, skipping DOM update');
1281
+ return decision;
1282
+ }
1283
+
1284
+ // Find and update each personalize slot in the DOM
1285
+ var slots = ['image', 'headline', 'subheadline', 'ctaLabel', 'ctaLink'];
1286
+ slots.forEach(function (slot) {
1287
+ var elements = findPersonalizeElements(slot);
1288
+ for (var i = 0; i < elements.length; i++) {
1289
+ var element = elements[i];
1290
+ var value = template[slot];
1291
+ if (!value) continue;
1292
+ if (slot === 'image') {
1293
+ // For images, set background-image or src attribute
1294
+ if (element.tagName === 'IMG') {
1295
+ element.src = value;
1296
+ element.alt = template.headline || 'Personalized image';
1297
+ } else {
1298
+ element.style.backgroundImage = 'url(' + value + ')';
1299
+ }
1300
+ } else if (slot === 'ctaLink') {
1301
+ // For links, set href attribute
1302
+ element.href = value;
1303
+ } else {
1304
+ // For text content, use textContent (safe, no HTML parsing)
1305
+ safeSetText(element, value);
1306
+ }
1307
+ }
1308
+ });
1309
+
1310
+ // Apply to generic 'hero' sections with data-personalize="hero"
1311
+ var heroElements = findPersonalizeElements('hero');
1312
+ for (var h = 0; h < heroElements.length; h++) {
1313
+ var heroEl = heroElements[h];
1314
+ heroEl.setAttribute('data-intent', decision.intent);
1315
+ heroEl.setAttribute('data-source', decision.source);
1316
+
1317
+ // If hero has a background image slot, apply it
1318
+ if (template.image && !heroEl.querySelector('[data-personalize="image"]')) {
1319
+ heroEl.style.backgroundImage = 'url(' + template.image + ')';
1320
+ }
1321
+ }
1322
+
1323
+ // Log the personalization decision (for debugging/demo)
1324
+ if (log && typeof console !== 'undefined') {
1325
+ console.log('[Lua Personalize] Applied:', {
1326
+ intent: decision.intent,
1327
+ source: decision.source,
1328
+ headline: template.headline,
1329
+ hasUTM: context.hasUTM,
1330
+ utmParams: context.utm || {},
1331
+ aiPowered: decision.source === 'ai' || decision.source === 'ai-cached'
1332
+ });
1333
+ }
1334
+ return decision;
1335
+ }
1336
+
1337
+ // ===================================================================
1338
+ // Context Resolution
1339
+ // ===================================================================
1340
+
1341
+ /**
1342
+ * Resolve context from available sources
1343
+ * @param {Object} [options] - Options with optional context
1344
+ * @returns {Object} - Resolved context
1345
+ */
1346
+ function resolveContext(options) {
1347
+ if (options && options.context) {
1348
+ return options.context;
1349
+ }
1350
+ if (root.LuaUTM && typeof root.LuaUTM.getContext === 'function') {
1351
+ return root.LuaUTM.getContext();
1352
+ }
1353
+
1354
+ // No UTM module available - create minimal default context
1355
+ return {
1356
+ utm: {},
1357
+ referrer: {
1358
+ source: 'direct',
1359
+ category: 'direct',
1360
+ url: ''
1361
+ },
1362
+ userAgent: {
1363
+ raw: '',
1364
+ isMobile: false,
1365
+ isTablet: false,
1366
+ isDesktop: true
1367
+ },
1368
+ timestamp: Date.now(),
1369
+ hasUTM: false,
1370
+ primaryIntent: 'default'
1371
+ };
1372
+ }
1373
+
1374
+ // ===================================================================
1375
+ // Main Personalization Functions
1376
+ // ===================================================================
1377
+
1378
+ /**
1379
+ * Apply personalization to the page via data-personalize attributes
1380
+ * Main entry point for personalization
1381
+ *
1382
+ * Supported data-personalize values:
1383
+ * - "hero" : Generic hero section (sets data-intent, data-source)
1384
+ * - "image" : Image slot (sets src or background-image)
1385
+ * - "headline" : Headline text
1386
+ * - "subheadline" : Subheadline text
1387
+ * - "ctaLabel" : CTA button text
1388
+ * - "ctaLink" : CTA link href
1389
+ *
1390
+ * @param {Object} [options] - Configuration options
1391
+ * @param {Object} [options.context] - Pre-computed UTM context
1392
+ * @param {Object} [options.rules] - Custom matching rules
1393
+ * @param {Object} options.templates - User-provided templates (REQUIRED)
1394
+ * @param {boolean} [options.enableAI] - Enable AI-powered decisions
1395
+ * @param {Object} [options.aiConfig] - AI configuration (required if enableAI is true)
1396
+ * @param {boolean} [options.randomFallback] - Enable random A/B fallback (default: true)
1397
+ * @param {boolean} [options.log] - Enable console logging (default: true)
1398
+ * @returns {Object|Promise<Object>} - Result with applied decision (Promise if AI enabled)
1399
+ */
1400
+ function personalize(options) {
1401
+ options = options || {};
1402
+
1403
+ // Templates are required
1404
+ if (!options.templates || typeof options.templates !== 'object' || Object.keys(options.templates).length === 0) {
1405
+ console.error('[Lua Personalize] Templates are required. Provide templates via options.templates');
1406
+ return {
1407
+ template: null,
1408
+ intent: 'default',
1409
+ source: 'error',
1410
+ context: {},
1411
+ error: 'No templates provided'
1412
+ };
1413
+ }
1414
+ var context = resolveContext(options);
1415
+ var decision = DecisionEngine.decide(context, options);
1416
+
1417
+ // If decision is a Promise (AI path), handle async flow
1418
+ if (decision && typeof decision.then === 'function') {
1419
+ return decision.then(function (aiDecision) {
1420
+ return applyDecisionToDOM(aiDecision, options);
1421
+ })["catch"](function (err) {
1422
+ console.warn('[Lua Personalize] AI decision failed, using standard:', err.message);
1423
+ // Fallback to standard decision + DOM application
1424
+ var fallbackDecision = DecisionEngine.standardDecide(context, options);
1425
+ return applyDecisionToDOM(fallbackDecision, options);
1426
+ });
1427
+ }
1428
+
1429
+ // Synchronous path (standard engine)
1430
+ return applyDecisionToDOM(decision, options);
1431
+ }
1432
+
1433
+ /**
1434
+ * Async personalization with timeout fallback
1435
+ * Uses LuaUTM.getContextAsync for non-blocking UTM extraction
1436
+ * Automatically handles AI decisions (which are always async)
1437
+ *
1438
+ * @param {Object} [options] - Configuration options
1439
+ * @returns {Promise<Object>} - Result with applied decision
1440
+ */
1441
+ function personalizeAsync(options) {
1442
+ options = options || {};
1443
+
1444
+ // Use async context getter if available
1445
+ if (root.LuaUTM && typeof root.LuaUTM.getContextAsync === 'function') {
1446
+ return root.LuaUTM.getContextAsync(options).then(function (context) {
1447
+ options.context = context;
1448
+ return personalize(options);
1449
+ }).then(function (decision) {
1450
+ // Ensure we always return a resolved promise
1451
+ return decision;
1452
+ })["catch"](function (err) {
1453
+ console.warn('[Lua Personalize] Async error, using default:', err);
1454
+ // Force standard engine fallback
1455
+ var fallbackOptions = {
1456
+ templates: options.templates,
1457
+ context: resolveContext(options),
1458
+ log: options.log
1459
+ };
1460
+ var fallbackDecision = DecisionEngine.standardDecide(fallbackOptions.context, fallbackOptions);
1461
+ return applyDecisionToDOM(fallbackDecision, fallbackOptions);
1462
+ });
1463
+ }
1464
+
1465
+ // Wrap synchronous/AI personalization in a promise
1466
+ try {
1467
+ var result = personalize(options);
1468
+ // If result is a promise (AI path), return it directly
1469
+ if (result && typeof result.then === 'function') {
1470
+ return result;
1471
+ }
1472
+ return Promise.resolve(result);
1473
+ } catch (err) {
1474
+ console.warn('[Lua Personalize] Error, using default:', err);
1475
+ var defaultContext = {
1476
+ utm: {},
1477
+ referrer: {
1478
+ source: 'direct',
1479
+ category: 'direct',
1480
+ url: ''
1481
+ },
1482
+ userAgent: {
1483
+ raw: '',
1484
+ isMobile: false,
1485
+ isTablet: false,
1486
+ isDesktop: true
1487
+ },
1488
+ timestamp: Date.now(),
1489
+ hasUTM: false,
1490
+ primaryIntent: 'default'
1491
+ };
1492
+ var fallback = DecisionEngine.standardDecide(defaultContext, {
1493
+ templates: options.templates
1494
+ });
1495
+ return Promise.resolve(applyDecisionToDOM(fallback, options));
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * Auto-initialize personalization when DOM is ready
1501
+ * Scans for data-personalize attributes and applies content
1502
+ * @param {Object} [options] - Configuration options
1503
+ */
1504
+ function autoInit(options) {
1505
+ options = options || {};
1506
+ function run() {
1507
+ // Check if there are any data-personalize elements on the page
1508
+ var elements = findPersonalizeElements();
1509
+ if (elements.length > 0) {
1510
+ personalize(options);
1511
+ }
1512
+ }
1513
+
1514
+ // Wait for DOM ready
1515
+ if (typeof document !== 'undefined') {
1516
+ if (document.readyState === 'loading') {
1517
+ document.addEventListener('DOMContentLoaded', run);
1518
+ } else {
1519
+ run();
1520
+ }
1521
+ }
1522
+ }
1523
+
1524
+ // ===================================================================
1525
+ // Public API - Register on window.LuaPersonalize
1526
+ // ===================================================================
1527
+
1528
+ var LuaPersonalize = {
1529
+ // Note: Templates are NOT provided by this package
1530
+ // Users must provide their own templates via options.templates
1531
+ sanitizer: Sanitizer,
1532
+ sanitizeHTML: function sanitizeHTML(html) {
1533
+ return Sanitizer.sanitize(html);
1534
+ },
1535
+ safeSetText: safeSetText,
1536
+ safeSetHTML: safeSetHTML,
1537
+ findElements: findPersonalizeElements,
1538
+ getTemplate: getTemplate,
1539
+ engine: DecisionEngine,
1540
+ personalize: personalize,
1541
+ personalizeAsync: personalizeAsync,
1542
+ autoInit: autoInit,
1543
+ chooseWeightedRandom: chooseWeightedRandom,
1544
+ getRandomFallbackIntent: getRandomFallbackIntent,
1545
+ applyDecisionToDOM: applyDecisionToDOM,
1546
+ resolveContext: resolveContext
1547
+ };
1548
+
1549
+ // Expose globally
1550
+ root.LuaPersonalize = LuaPersonalize;
1551
+ })(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : undefined);
1552
+
1553
+ // this is the build for webpack and UMD builds
1554
+ var stores = {
1555
+ browserCookie: browserCookie(),
1556
+ local: local(),
1557
+ memory: memory()
1558
+ };
1559
+ window.Lua = Lua;
1560
+ Lua.stores = stores;
1561
+
1562
+ // Attach UTM and Personalization from window globals (populated by IIFEs)
1563
+ Lua.utm = window.LuaUTM || {};
1564
+ Lua.personalization = window.LuaPersonalize || {};
1565
+
1566
+ export default Lua;