@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.
- package/README.md +552 -0
- package/build/es5/__tests__/ai-personalize.test.js +811 -0
- package/build/es5/__tests__/lua.js +134 -0
- package/build/es5/__tests__/original-roughly.js +197 -0
- package/build/es5/__tests__/original.js +174 -0
- package/build/es5/__tests__/unit.js +72 -0
- package/build/es5/__tests__/weighted-history.test.js +376 -0
- package/build/es5/ai-personalize.js +641 -0
- package/build/es5/index.js +30 -0
- package/build/es5/lua.js +366 -0
- package/build/es5/personalization.js +811 -0
- package/build/es5/prompts/personalization-prompts.js +260 -0
- package/build/es5/storage/weighted-history.js +384 -0
- package/build/es5/stores/browser-cookie.js +25 -0
- package/build/es5/stores/local.js +29 -0
- package/build/es5/stores/memory.js +22 -0
- package/build/es5/utils.js +54 -0
- package/build/es5/utm-personalize.js +817 -0
- package/build/es5/utm.js +304 -0
- package/build/lua.dev.js +1574 -0
- package/build/lua.es.js +1566 -0
- package/build/lua.js +1574 -0
- package/build/lua.min.js +8 -0
- package/package.json +68 -0
package/build/lua.dev.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
})));
|