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