webhanger 1.0.8 → 1.0.9
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 +462 -321
- package/bin/cli.js +266 -6
- package/helper/authConfig.js +68 -0
- package/helper/bundler.js +3 -2
- package/helper/dbHandler.js +11 -4
- package/helper/oauthHandler.js +149 -0
- package/helper/personalization.js +138 -0
- package/package.json +4 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebHanger Personalization Engine
|
|
3
|
+
* Resolves which component variant to serve based on rules.
|
|
4
|
+
*
|
|
5
|
+
* Rules are evaluated top-to-bottom — first match wins.
|
|
6
|
+
* Falls back to "default" if no rule matches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Context builders ─────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the personalization context from available signals.
|
|
13
|
+
* Works in both browser and Node.js (edge worker).
|
|
14
|
+
*/
|
|
15
|
+
export function buildContext(options = {}) {
|
|
16
|
+
const ctx = {};
|
|
17
|
+
|
|
18
|
+
// Country — from CF header (edge) or options
|
|
19
|
+
ctx.country = options.country || (typeof navigator === "undefined" ? null : null);
|
|
20
|
+
|
|
21
|
+
// Device — from User-Agent
|
|
22
|
+
const ua = options.userAgent ||
|
|
23
|
+
(typeof navigator !== "undefined" ? navigator.userAgent : "");
|
|
24
|
+
ctx.device = detectDevice(ua);
|
|
25
|
+
|
|
26
|
+
// Language — from browser or options
|
|
27
|
+
ctx.lang = options.lang ||
|
|
28
|
+
(typeof navigator !== "undefined" ? (navigator.language || "").split("-")[0] : "en");
|
|
29
|
+
|
|
30
|
+
// Role — from JWT token (WHAuth) or options
|
|
31
|
+
ctx.role = options.role || getStoredRole();
|
|
32
|
+
|
|
33
|
+
// A/B test bucket — stable per user, stored in localStorage
|
|
34
|
+
ctx.abTest = options.abTest || getABBucket();
|
|
35
|
+
|
|
36
|
+
// Hour of day (0-23) for time-based rules
|
|
37
|
+
ctx.hour = options.hour !== undefined ? options.hour : new Date().getHours();
|
|
38
|
+
|
|
39
|
+
// Subscription plan — from options or localStorage
|
|
40
|
+
ctx.plan = options.plan ||
|
|
41
|
+
(typeof localStorage !== "undefined" ? localStorage.getItem("wh_plan") || "free" : "free");
|
|
42
|
+
|
|
43
|
+
// Custom context — merge any extra fields
|
|
44
|
+
if (options.custom) Object.assign(ctx, options.custom);
|
|
45
|
+
|
|
46
|
+
return ctx;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectDevice(ua) {
|
|
50
|
+
if (!ua) return "desktop";
|
|
51
|
+
if (/tablet|ipad/i.test(ua)) return "tablet";
|
|
52
|
+
if (/mobile|android|iphone|ipod|blackberry|windows phone/i.test(ua)) return "mobile";
|
|
53
|
+
return "desktop";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getStoredRole() {
|
|
57
|
+
if (typeof localStorage === "undefined") return null;
|
|
58
|
+
try {
|
|
59
|
+
const token = localStorage.getItem("wh_auth_token");
|
|
60
|
+
if (!token) return null;
|
|
61
|
+
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
62
|
+
return payload.role || null;
|
|
63
|
+
} catch (_) { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getABBucket() {
|
|
67
|
+
if (typeof localStorage === "undefined") return "variant-a";
|
|
68
|
+
let bucket = localStorage.getItem("wh_ab_bucket");
|
|
69
|
+
if (!bucket) {
|
|
70
|
+
bucket = Math.random() < 0.5 ? "variant-a" : "variant-b";
|
|
71
|
+
localStorage.setItem("wh_ab_bucket", bucket);
|
|
72
|
+
}
|
|
73
|
+
return bucket;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Rule evaluator ───────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Evaluate a single rule condition against the context.
|
|
80
|
+
*/
|
|
81
|
+
function matchesCondition(condition, ctx) {
|
|
82
|
+
for (const [key, value] of Object.entries(condition)) {
|
|
83
|
+
const ctxVal = ctx[key];
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
// Array = any of these values
|
|
87
|
+
if (!value.includes(ctxVal)) return false;
|
|
88
|
+
} else if (typeof value === "object" && value !== null) {
|
|
89
|
+
// Range: { min: 9, max: 17 }
|
|
90
|
+
if (value.min !== undefined && ctxVal < value.min) return false;
|
|
91
|
+
if (value.max !== undefined && ctxVal > value.max) return false;
|
|
92
|
+
} else {
|
|
93
|
+
// Exact match (case-insensitive for strings)
|
|
94
|
+
const a = typeof ctxVal === "string" ? ctxVal.toLowerCase() : ctxVal;
|
|
95
|
+
const b = typeof value === "string" ? value.toLowerCase() : value;
|
|
96
|
+
if (a !== b) return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve which component to load for a slot based on rules + context.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} slotName - e.g. "hero"
|
|
106
|
+
* @param {object} rules - rules config for this slot
|
|
107
|
+
* @param {object} ctx - personalization context
|
|
108
|
+
* @returns {string} - resolved component name
|
|
109
|
+
*/
|
|
110
|
+
export function resolveComponent(slotName, rules, ctx) {
|
|
111
|
+
if (!rules || !rules.rules) return rules?.default || slotName;
|
|
112
|
+
|
|
113
|
+
for (const rule of rules.rules) {
|
|
114
|
+
if (matchesCondition(rule.if || {}, ctx)) {
|
|
115
|
+
return rule.component;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return rules.default || slotName;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve all slots in a personalization config.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} config - full wh-personalization.json
|
|
126
|
+
* @param {object} options - context overrides
|
|
127
|
+
* @returns {object} - { slotName: resolvedComponentName }
|
|
128
|
+
*/
|
|
129
|
+
export function resolveAll(config, options = {}) {
|
|
130
|
+
const ctx = buildContext(options);
|
|
131
|
+
const resolved = {};
|
|
132
|
+
|
|
133
|
+
for (const [slot, rules] of Object.entries(config)) {
|
|
134
|
+
resolved[slot] = resolveComponent(slot, rules, ctx);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { resolved, ctx };
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhanger",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Component-as-a-Service platform — bundle, sign, and deliver UI components via edge CDN",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"chalk": "^5.6.2",
|
|
36
36
|
"firebase-admin": "^12.0.0",
|
|
37
37
|
"fs-extra": "^11.3.4",
|
|
38
|
-
"inquirer": "^13.4.1"
|
|
38
|
+
"inquirer": "^13.4.1",
|
|
39
|
+
"webhanger-admin": "^1.0.0",
|
|
40
|
+
"webhanger-auth": "^1.0.0"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"terser": "^5.46.1"
|