webhanger 1.0.9 → 1.1.0
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 +146 -1
- package/bin/cli.js +38 -1
- package/core/devServer.js +337 -0
- package/helper/analyzer.js +32 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,20 @@ await load(c.urls || c.url, manifest.pid, c.token, c.expires, "#nav-mount");
|
|
|
54
54
|
### `wh init`
|
|
55
55
|
Interactive setup. Provisions S3 bucket + CloudFront automatically. Supports Firebase, Supabase, MongoDB. Optional Cloudflare Edge Worker setup.
|
|
56
56
|
|
|
57
|
+
### `wh dev` ⭐ Development server
|
|
58
|
+
Local dev server with hot reload, live preview, and manifest auto-refresh.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
wh dev ./components 4242
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Edit any component file → auto-deploys in 300ms → browser hot-reloads. Open `http://localhost:4242` for live preview with a dev status bar at the bottom.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Custom port + manifest output
|
|
68
|
+
wh dev ./components 3000 ./public/wh-manifest.json
|
|
69
|
+
```
|
|
70
|
+
|
|
57
71
|
### `wh ship` ⭐
|
|
58
72
|
Deploy + build + zip in one shot.
|
|
59
73
|
```bash
|
|
@@ -273,7 +287,133 @@ const graph = await resolveGraph(config.db, projectId, "dashboard", "1.0.0");
|
|
|
273
287
|
|
|
274
288
|
---
|
|
275
289
|
|
|
276
|
-
##
|
|
290
|
+
## Edge Personalization
|
|
291
|
+
|
|
292
|
+
Serve different component variants to different users based on rules — country, device, role, A/B test, subscription plan — all resolved client-side or at the Cloudflare edge.
|
|
293
|
+
|
|
294
|
+
### Setup
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
# Scaffold rules config
|
|
298
|
+
wh personalize init
|
|
299
|
+
|
|
300
|
+
# Test rule resolution (slot, config, country, device, role)
|
|
301
|
+
wh personalize test ./wh-personalization.json IN mobile premium
|
|
302
|
+
# Output:
|
|
303
|
+
# Context: country=IN device=mobile role=premium
|
|
304
|
+
# Resolved: hero → hero-india (first matching rule: country=IN)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### `wh-personalization.json`
|
|
308
|
+
|
|
309
|
+
> **Key rule:** The slot key (e.g. `"hero"`) **must exactly match** the `name` attribute on `<wh-component name="hero">`. The SDK uses the component name as the slot lookup key.
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"hero": {
|
|
314
|
+
"rules": [
|
|
315
|
+
{ "if": { "country": "IN" }, "component": "hero-india" },
|
|
316
|
+
{ "if": { "country": "US" }, "component": "hero-us" },
|
|
317
|
+
{ "if": { "device": "mobile" }, "component": "hero-mobile" },
|
|
318
|
+
{ "if": { "role": "premium" }, "component": "hero-premium" },
|
|
319
|
+
{ "if": { "abTest": "variant-b" }, "component": "hero-variant-b"},
|
|
320
|
+
{ "if": { "hour": { "min": 9, "max": 17 } }, "component": "hero-business" }
|
|
321
|
+
],
|
|
322
|
+
"default": "hero"
|
|
323
|
+
},
|
|
324
|
+
"navbar": {
|
|
325
|
+
"rules": [
|
|
326
|
+
{ "if": { "role": "admin" }, "component": "navbar-admin" },
|
|
327
|
+
{ "if": { "plan": "premium" }, "component": "navbar-premium" }
|
|
328
|
+
],
|
|
329
|
+
"default": "navbar"
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Zero-code usage
|
|
335
|
+
|
|
336
|
+
```html
|
|
337
|
+
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
338
|
+
<script>
|
|
339
|
+
// Load rules first, then initialize
|
|
340
|
+
WebHangerFront.loadPersonalization("./wh-personalization.json");
|
|
341
|
+
WebHangerFront.initialize("./wh-manifest.json");
|
|
342
|
+
</script>
|
|
343
|
+
|
|
344
|
+
<!-- personalize attribute enables rule resolution -->
|
|
345
|
+
<wh-component name="hero" personalize></wh-component>
|
|
346
|
+
<wh-component name="navbar" personalize></wh-component>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Programmatic
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
import { load, loadPersonalization } from "webhanger-front";
|
|
353
|
+
|
|
354
|
+
await loadPersonalization("./wh-personalization.json");
|
|
355
|
+
|
|
356
|
+
// Override context for testing
|
|
357
|
+
window._wh_ctx_override = { country: "IN", role: "premium" };
|
|
358
|
+
|
|
359
|
+
// Component resolves to hero-india (first matching rule)
|
|
360
|
+
await load(manifest.components["hero"].url, pid, token, 0, "#hero");
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Rule conditions
|
|
364
|
+
|
|
365
|
+
| Condition | Source | Example |
|
|
366
|
+
|---|---|---|
|
|
367
|
+
| `country` | `window._wh_ctx_override` or IP (edge) | `"IN"`, `["IN","PK"]` |
|
|
368
|
+
| `device` | User-Agent | `"mobile"`, `"tablet"`, `"desktop"` |
|
|
369
|
+
| `role` | JWT from `webhanger-auth` | `"premium"`, `"admin"` |
|
|
370
|
+
| `abTest` | localStorage bucket (stable per user) | `"variant-a"`, `"variant-b"` |
|
|
371
|
+
| `lang` | `navigator.language` | `"en"`, `"hi"` |
|
|
372
|
+
| `hour` | time of day | `{ "min": 9, "max": 17 }` |
|
|
373
|
+
| `plan` | localStorage `wh_plan` | `"free"`, `"premium"` |
|
|
374
|
+
|
|
375
|
+
Rules are evaluated top-to-bottom — first match wins. Falls back to `default` if no rule matches.
|
|
376
|
+
|
|
377
|
+
### Events
|
|
378
|
+
|
|
379
|
+
```js
|
|
380
|
+
WebHangerFront.on("personalized", ({ slot, resolved, ctx }) => {
|
|
381
|
+
console.log(`${slot} → ${resolved} (country: ${ctx.country})`);
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### A/B Testing
|
|
386
|
+
|
|
387
|
+
Each user is automatically assigned a stable bucket (`variant-a` or `variant-b`) stored in localStorage. 50/50 split by default.
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
// Check current bucket
|
|
391
|
+
console.log(localStorage.getItem("wh_ab_bucket")); // "variant-a" or "variant-b"
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Edge resolution (Cloudflare Workers)
|
|
395
|
+
|
|
396
|
+
When using `wh edge-init`, the worker resolves personalization server-side using Cloudflare headers — no client-side JS needed:
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
CF-IPCountry: IN → worker resolves hero-india → serves encrypted component
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Store rules in KV:
|
|
403
|
+
```bash
|
|
404
|
+
wrangler kv:key put --binding=WH_VERSIONS "personalization:hero" '{"rules":[...],"default":"hero"}'
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Test it
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
node personalization-test/deploy.js
|
|
411
|
+
npx serve personalization-test/site
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Open `http://localhost:3000` — use the simulator buttons to switch country, device, role, and A/B bucket. Watch the hero component change in real time.
|
|
415
|
+
|
|
416
|
+
---
|
|
277
417
|
|
|
278
418
|
Drop-in OAuth for any website. Supports Google, GitHub, Facebook. Zero backend code required beyond `wh auth serve`.
|
|
279
419
|
|
|
@@ -650,6 +790,7 @@ export default function WebHangerComponent({ name, props = {} }: { name: string;
|
|
|
650
790
|
| `showcase/` | All 4 packages together |
|
|
651
791
|
| `auth-test/` | OAuth login flow (Google, GitHub) |
|
|
652
792
|
| `smart-cache-test/` | Smart cache invalidation demo |
|
|
793
|
+
| `personalization-test/` | Edge personalization — country, device, role, A/B |
|
|
653
794
|
|
|
654
795
|
```bash
|
|
655
796
|
# Examples
|
|
@@ -666,6 +807,10 @@ npx serve . # open /auth-test/login.html
|
|
|
666
807
|
node smart-cache-test/deploy.js 1.0.0
|
|
667
808
|
node admin/server.js ./webhanger.config.json 5000 &
|
|
668
809
|
npx serve smart-cache-test/site
|
|
810
|
+
|
|
811
|
+
# Personalization test
|
|
812
|
+
node personalization-test/deploy.js
|
|
813
|
+
npx serve personalization-test/site
|
|
669
814
|
```
|
|
670
815
|
|
|
671
816
|
See `examples/EXAMPLE.md` for the full step-by-step guide.
|
package/bin/cli.js
CHANGED
|
@@ -460,6 +460,29 @@ ${mounts}
|
|
|
460
460
|
}
|
|
461
461
|
break;
|
|
462
462
|
}
|
|
463
|
+
case "dev": {
|
|
464
|
+
const devDir = args[1] || "./components";
|
|
465
|
+
const devPort = parseInt(args[2] || "4242");
|
|
466
|
+
const devManifest = args[3] || "./wh-manifest.json";
|
|
467
|
+
|
|
468
|
+
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
469
|
+
const { startDevServer } = await import("../core/devServer.js");
|
|
470
|
+
|
|
471
|
+
const config = loadConfigFn();
|
|
472
|
+
|
|
473
|
+
console.log(chalk.cyan("\n⚡ wh dev — WebHanger Development Server\n"));
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
await startDevServer(config, devDir, {
|
|
477
|
+
port: devPort,
|
|
478
|
+
manifestOut: devManifest
|
|
479
|
+
});
|
|
480
|
+
} catch (err) {
|
|
481
|
+
console.log(chalk.red(`\n❌ Dev server failed: ${err.message}`));
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
463
486
|
case "personalize": {
|
|
464
487
|
const { default: fsExtra } = await import("fs-extra");
|
|
465
488
|
const { default: pathMod } = await import("path");
|
|
@@ -1102,7 +1125,7 @@ ${mounts}
|
|
|
1102
1125
|
console.log(chalk.red("Usage: wh analyze <component-dir>"));
|
|
1103
1126
|
process.exit(1);
|
|
1104
1127
|
}
|
|
1105
|
-
const { analyzeComponent } = await import("../helper/analyzer.js");
|
|
1128
|
+
const { analyzeComponent, autoGenerateComponentMeta } = await import("../helper/analyzer.js");
|
|
1106
1129
|
const result = await analyzeComponent(dir);
|
|
1107
1130
|
console.log(chalk.cyan("\n🔍 Component Analysis\n"));
|
|
1108
1131
|
console.log(chalk.white(`Framework : ${result.framework}`));
|
|
@@ -1114,12 +1137,26 @@ ${mounts}
|
|
|
1114
1137
|
} else {
|
|
1115
1138
|
console.log(chalk.gray(" none"));
|
|
1116
1139
|
}
|
|
1140
|
+
|
|
1141
|
+
// Also run autoGenerate to show props
|
|
1142
|
+
const { meta } = await autoGenerateComponentMeta(dir);
|
|
1143
|
+
const propKeys = Object.keys(meta.props || {});
|
|
1144
|
+
if (propKeys.length) {
|
|
1145
|
+
console.log(chalk.white(`\nProps detected ({{wh.*}} placeholders):`));
|
|
1146
|
+
propKeys.forEach(k => {
|
|
1147
|
+
const p = meta.props[k];
|
|
1148
|
+
console.log(chalk.gray(` ${k.padEnd(20)} default: "${p.default || ""}"`));
|
|
1149
|
+
});
|
|
1150
|
+
console.log(chalk.green(`\n✅ webhanger.component.json updated with ${propKeys.length} props`));
|
|
1151
|
+
}
|
|
1152
|
+
console.log();
|
|
1117
1153
|
break;
|
|
1118
1154
|
}
|
|
1119
1155
|
default:
|
|
1120
1156
|
console.log(chalk.cyan(BANNER));
|
|
1121
1157
|
console.log(chalk.white("Commands:"));
|
|
1122
1158
|
console.log(chalk.gray(" wh init — setup your project"));
|
|
1159
|
+
console.log(chalk.gray(" wh dev [comp-dir] [port] [manifest-out] — dev server with hot reload + live preview"));
|
|
1123
1160
|
console.log(chalk.gray(" wh ship <comp-dir> <site-dir> [version] [out-dir] — deploy + build + zip in one shot"));
|
|
1124
1161
|
console.log(chalk.gray(" wh deploy <dir> <name> <version> — deploy a single component"));
|
|
1125
1162
|
console.log(chalk.gray(" wh graph-deploy <comp-dir> [version] [out-dir] — deploy all + resolve dep graph"));
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebHanger Dev Server
|
|
3
|
+
* - Watches component files for changes
|
|
4
|
+
* - Auto-deploys on save (bundle → encrypt → upload → register)
|
|
5
|
+
* - Serves a live preview with hot reload
|
|
6
|
+
* - Auto-refreshes wh-manifest.json
|
|
7
|
+
* - Tests dependency graph on every change
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from "http";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
// ─── File watcher (no deps — uses fs.watch) ───────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function watchDir(dir, onChange) {
|
|
20
|
+
const watchers = new Map();
|
|
21
|
+
|
|
22
|
+
function watchRecursive(d) {
|
|
23
|
+
if (!fs.existsSync(d)) return;
|
|
24
|
+
const watcher = fs.watch(d, { recursive: false }, (event, filename) => {
|
|
25
|
+
if (filename) onChange(path.join(d, filename), event);
|
|
26
|
+
});
|
|
27
|
+
watchers.set(d, watcher);
|
|
28
|
+
|
|
29
|
+
for (const entry of fs.readdirSync(d)) {
|
|
30
|
+
const full = path.join(d, entry);
|
|
31
|
+
if (fs.statSync(full).isDirectory()) watchRecursive(full);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
watchRecursive(dir);
|
|
36
|
+
return () => watchers.forEach(w => w.close());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── SSE broadcaster (for hot reload) ────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function createSSE() {
|
|
42
|
+
const clients = new Set();
|
|
43
|
+
|
|
44
|
+
function addClient(res) {
|
|
45
|
+
res.writeHead(200, {
|
|
46
|
+
"Content-Type": "text/event-stream",
|
|
47
|
+
"Cache-Control": "no-cache",
|
|
48
|
+
"Connection": "keep-alive",
|
|
49
|
+
"Access-Control-Allow-Origin": "*"
|
|
50
|
+
});
|
|
51
|
+
res.write("data: connected\n\n");
|
|
52
|
+
clients.add(res);
|
|
53
|
+
res.on("close", () => clients.delete(res));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function broadcast(event, data) {
|
|
57
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
58
|
+
clients.forEach(res => { try { res.write(msg); } catch (_) {} });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { addClient, broadcast, clientCount: () => clients.size };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Dev server ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export async function startDevServer(config, componentsDir, options = {}) {
|
|
67
|
+
const PORT = options.port || 4242;
|
|
68
|
+
const manifestOut = options.manifestOut || path.join(process.cwd(), "wh-manifest.json");
|
|
69
|
+
|
|
70
|
+
const { deploy } = await import("./registry.js");
|
|
71
|
+
const { resolveGraph } = await import("./resolver.js");
|
|
72
|
+
|
|
73
|
+
const sse = createSSE();
|
|
74
|
+
const deployQueue = new Map(); // debounce per component
|
|
75
|
+
const deployedComponents = new Map();
|
|
76
|
+
|
|
77
|
+
// ── Scan component folders ────────────────────────────────────────────────
|
|
78
|
+
async function scanComponents() {
|
|
79
|
+
const entries = await fs.readdir(componentsDir);
|
|
80
|
+
const comps = [];
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const full = path.join(componentsDir, entry);
|
|
83
|
+
const stat = await fs.stat(full);
|
|
84
|
+
if (stat.isDirectory()) comps.push({ name: entry, dir: full });
|
|
85
|
+
}
|
|
86
|
+
return comps;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Deploy a single component ─────────────────────────────────────────────
|
|
90
|
+
async function deployComponent(name, dir, version = "dev") {
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const result = await deploy(config, dir, name, version);
|
|
94
|
+
deployedComponents.set(name, result);
|
|
95
|
+
await refreshManifest();
|
|
96
|
+
|
|
97
|
+
const elapsed = Date.now() - start;
|
|
98
|
+
sse.broadcast("component-updated", {
|
|
99
|
+
name, version, cdnUrl: result.cdnUrl,
|
|
100
|
+
time: elapsed, ts: Date.now()
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { success: true, name, elapsed };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
sse.broadcast("deploy-error", { name, message: err.message });
|
|
106
|
+
return { success: false, name, error: err.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Refresh manifest ──────────────────────────────────────────────────────
|
|
111
|
+
async function refreshManifest() {
|
|
112
|
+
const manifest = {
|
|
113
|
+
pid: config.projectId,
|
|
114
|
+
components: {}
|
|
115
|
+
};
|
|
116
|
+
for (const [name, d] of deployedComponents) {
|
|
117
|
+
manifest.components[name] = {
|
|
118
|
+
url: d.cdnUrl,
|
|
119
|
+
urls: d.cdnUrls || [d.cdnUrl],
|
|
120
|
+
token: d.token,
|
|
121
|
+
expires: d.expires
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
await fs.writeJson(manifestOut, manifest, { spaces: 2 });
|
|
125
|
+
sse.broadcast("manifest-updated", { componentCount: deployedComponents.size, ts: Date.now() });
|
|
126
|
+
return manifest;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Test dependency graph ─────────────────────────────────────────────────
|
|
130
|
+
async function testDependencyGraph(rootName) {
|
|
131
|
+
try {
|
|
132
|
+
const graph = await resolveGraph(config.db, config.projectId, rootName, "dev");
|
|
133
|
+
sse.broadcast("dep-graph", { root: rootName, graph: graph.map(c => `${c.name}@${c.version}`) });
|
|
134
|
+
return graph;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
sse.broadcast("dep-error", { root: rootName, message: err.message });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Watch for file changes ────────────────────────────────────────────────
|
|
142
|
+
const stopWatching = watchDir(componentsDir, (filePath, event) => {
|
|
143
|
+
const rel = path.relative(componentsDir, filePath);
|
|
144
|
+
const compName = rel.split(path.sep)[0];
|
|
145
|
+
if (!compName) return;
|
|
146
|
+
|
|
147
|
+
// Debounce — wait 300ms after last change before deploying
|
|
148
|
+
if (deployQueue.has(compName)) clearTimeout(deployQueue.get(compName));
|
|
149
|
+
deployQueue.set(compName, setTimeout(async () => {
|
|
150
|
+
deployQueue.delete(compName);
|
|
151
|
+
const compDir = path.join(componentsDir, compName);
|
|
152
|
+
if (!fs.existsSync(compDir)) return;
|
|
153
|
+
console.log(`[wh dev] 🔄 ${compName} changed — redeploying...`);
|
|
154
|
+
const result = await deployComponent(compName, compDir);
|
|
155
|
+
if (result.success) {
|
|
156
|
+
console.log(`[wh dev] ✅ ${compName} deployed in ${result.elapsed}ms`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`[wh dev] ❌ ${compName}: ${result.error}`);
|
|
159
|
+
}
|
|
160
|
+
}, 300));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── HTTP server ───────────────────────────────────────────────────────────
|
|
164
|
+
const server = http.createServer(async (req, res) => {
|
|
165
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
166
|
+
const p = url.pathname;
|
|
167
|
+
|
|
168
|
+
// CORS
|
|
169
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
170
|
+
|
|
171
|
+
// SSE endpoint — browser connects for hot reload
|
|
172
|
+
if (p === "/__wh_dev__/events") {
|
|
173
|
+
sse.addClient(res); return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Status
|
|
177
|
+
if (p === "/__wh_dev__/status") {
|
|
178
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
179
|
+
res.end(JSON.stringify({
|
|
180
|
+
running: true,
|
|
181
|
+
components: [...deployedComponents.keys()],
|
|
182
|
+
clients: sse.clientCount(),
|
|
183
|
+
manifestPath: manifestOut
|
|
184
|
+
})); return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Trigger manual redeploy
|
|
188
|
+
if (p === "/__wh_dev__/redeploy" && req.method === "POST") {
|
|
189
|
+
const comps = await scanComponents();
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const comp of comps) {
|
|
192
|
+
const r = await deployComponent(comp.name, comp.dir);
|
|
193
|
+
results.push(r);
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
196
|
+
res.end(JSON.stringify({ results })); return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Serve preview page
|
|
200
|
+
if (p === "/" || p === "/index.html") {
|
|
201
|
+
const manifest = await refreshManifest().catch(() => ({ components: {} }));
|
|
202
|
+
const mounts = Object.keys(manifest.components)
|
|
203
|
+
.map(name => ` <wh-component name="${name}"></wh-component>`)
|
|
204
|
+
.join("\n");
|
|
205
|
+
|
|
206
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
207
|
+
res.end(previewPage(mounts, PORT)); return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Serve manifest
|
|
211
|
+
if (p === "/wh-manifest.json") {
|
|
212
|
+
if (fs.existsSync(manifestOut)) {
|
|
213
|
+
const data = await fs.readFile(manifestOut, "utf-8");
|
|
214
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
215
|
+
res.end(data);
|
|
216
|
+
} else {
|
|
217
|
+
res.writeHead(404); res.end("{}");
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
res.writeHead(404); res.end("Not found");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── Initial deploy ────────────────────────────────────────────────────────
|
|
226
|
+
console.log(`[wh dev] 🔍 Scanning ${componentsDir}...`);
|
|
227
|
+
const comps = await scanComponents();
|
|
228
|
+
console.log(`[wh dev] Found ${comps.length} components — deploying...\n`);
|
|
229
|
+
|
|
230
|
+
for (const comp of comps) {
|
|
231
|
+
process.stdout.write(` ${comp.name}... `);
|
|
232
|
+
const r = await deployComponent(comp.name, comp.dir);
|
|
233
|
+
console.log(r.success ? `✅ (${r.elapsed}ms)` : `❌ ${r.error}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
server.listen(PORT, () => {
|
|
237
|
+
console.log(`\n[wh dev] 🚀 Dev server running`);
|
|
238
|
+
console.log(` Preview : http://localhost:${PORT}`);
|
|
239
|
+
console.log(` Manifest : http://localhost:${PORT}/wh-manifest.json`);
|
|
240
|
+
console.log(` Status : http://localhost:${PORT}/__wh_dev__/status`);
|
|
241
|
+
console.log(`\n[wh dev] 👀 Watching ${componentsDir} for changes...\n`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return { server, stopWatching };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Preview page HTML ────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function previewPage(mounts, port) {
|
|
250
|
+
return `<!DOCTYPE html>
|
|
251
|
+
<html lang="en">
|
|
252
|
+
<head>
|
|
253
|
+
<meta charset="UTF-8">
|
|
254
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
255
|
+
<title>wh dev — Live Preview</title>
|
|
256
|
+
<style>
|
|
257
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
258
|
+
body{background:#030712;color:#e8eaf0;font-family:system-ui,sans-serif}
|
|
259
|
+
#wh-dev-bar{
|
|
260
|
+
position:fixed;bottom:0;left:0;right:0;z-index:9999;
|
|
261
|
+
background:#0d0e12;border-top:1px solid #1e2028;
|
|
262
|
+
padding:8px 16px;display:flex;align-items:center;gap:12px;
|
|
263
|
+
font-family:monospace;font-size:11px;
|
|
264
|
+
}
|
|
265
|
+
.dev-dot{width:7px;height:7px;border-radius:50%;background:#4ade80;animation:pulse 2s infinite}
|
|
266
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
267
|
+
.dev-status{color:#4ade80;font-weight:600}
|
|
268
|
+
.dev-log{color:#6b7280;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
269
|
+
.dev-reload{background:none;border:1px solid #374151;color:#9ca3af;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:monospace}
|
|
270
|
+
.dev-reload:hover{border-color:#6366f1;color:#818cf8}
|
|
271
|
+
</style>
|
|
272
|
+
</head>
|
|
273
|
+
<body>
|
|
274
|
+
|
|
275
|
+
${mounts}
|
|
276
|
+
|
|
277
|
+
<div id="wh-dev-bar">
|
|
278
|
+
<div class="dev-dot" id="dev-dot"></div>
|
|
279
|
+
<span class="dev-status" id="dev-status">wh dev</span>
|
|
280
|
+
<span class="dev-log" id="dev-log">Connected — watching for changes</span>
|
|
281
|
+
<button class="dev-reload" onclick="location.reload()">↻ Reload</button>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
285
|
+
<script>
|
|
286
|
+
WebHangerFront.initialize("/wh-manifest.json");
|
|
287
|
+
|
|
288
|
+
// ── Hot reload via SSE
|
|
289
|
+
var es = new EventSource("/__wh_dev__/events");
|
|
290
|
+
var dot = document.getElementById("dev-dot");
|
|
291
|
+
var status = document.getElementById("dev-status");
|
|
292
|
+
var log = document.getElementById("dev-log");
|
|
293
|
+
|
|
294
|
+
function setLog(msg, color) {
|
|
295
|
+
log.textContent = msg;
|
|
296
|
+
if (color) log.style.color = color;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
es.addEventListener("component-updated", function(e) {
|
|
300
|
+
var d = JSON.parse(e.data);
|
|
301
|
+
dot.style.background = "#fbbf24";
|
|
302
|
+
setLog("🔄 " + d.name + " updated (" + d.time + "ms) — reloading...", "#fbbf24");
|
|
303
|
+
setTimeout(function() { location.reload(); }, 500);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
es.addEventListener("deploy-error", function(e) {
|
|
307
|
+
var d = JSON.parse(e.data);
|
|
308
|
+
dot.style.background = "#f87171";
|
|
309
|
+
setLog("❌ " + d.name + ": " + d.message, "#f87171");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
es.addEventListener("manifest-updated", function(e) {
|
|
313
|
+
var d = JSON.parse(e.data);
|
|
314
|
+
setLog("📋 Manifest updated — " + d.componentCount + " components", "#60a5fa");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
es.addEventListener("dep-graph", function(e) {
|
|
318
|
+
var d = JSON.parse(e.data);
|
|
319
|
+
setLog("🔗 Dep graph: " + d.graph.join(" → "), "#c084fc");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
es.onopen = function() {
|
|
323
|
+
dot.style.background = "#4ade80";
|
|
324
|
+
status.textContent = "wh dev";
|
|
325
|
+
setLog("Connected — watching for changes", "#4ade80");
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
es.onerror = function() {
|
|
329
|
+
dot.style.background = "#f87171";
|
|
330
|
+
status.textContent = "disconnected";
|
|
331
|
+
setLog("Dev server disconnected — retrying...", "#f87171");
|
|
332
|
+
};
|
|
333
|
+
</script>
|
|
334
|
+
|
|
335
|
+
</body>
|
|
336
|
+
</html>`;
|
|
337
|
+
}
|
package/helper/analyzer.js
CHANGED
|
@@ -162,21 +162,48 @@ export async function analyzeComponent(componentDir) {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* Auto-generates webhanger.component.json if it doesn't exist.
|
|
165
|
-
* If it exists, merges new assets without overwriting manual ones.
|
|
165
|
+
* If it exists, merges new assets + props without overwriting manual ones.
|
|
166
166
|
*/
|
|
167
167
|
export async function autoGenerateComponentMeta(componentDir) {
|
|
168
168
|
const metaPath = path.join(componentDir, "webhanger.component.json");
|
|
169
169
|
const analysis = await analyzeComponent(componentDir);
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
// Extract {{wh.propName}} placeholders from HTML
|
|
172
|
+
const htmlPath = path.join(componentDir, "index.html");
|
|
173
|
+
const props = {};
|
|
174
|
+
if (await fs.pathExists(htmlPath)) {
|
|
175
|
+
const html = await fs.readFile(htmlPath, "utf-8");
|
|
176
|
+
const matches = [...html.matchAll(/\{\{wh\.([a-zA-Z0-9_]+)\}\}/g)];
|
|
177
|
+
for (const m of matches) {
|
|
178
|
+
const propName = m[1];
|
|
179
|
+
if (!props[propName]) {
|
|
180
|
+
props[propName] = { type: "string", default: "" };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let existing = { assets: [], dependencies: [], props: {} };
|
|
172
186
|
if (await fs.pathExists(metaPath)) {
|
|
173
187
|
existing = await fs.readJson(metaPath);
|
|
188
|
+
if (!existing.props) existing.props = {};
|
|
174
189
|
}
|
|
175
190
|
|
|
176
|
-
// Merge — don't duplicate URLs
|
|
177
|
-
const existingUrls = new Set(existing.assets.map(a => a.url));
|
|
191
|
+
// Merge assets — don't duplicate URLs
|
|
192
|
+
const existingUrls = new Set((existing.assets || []).map(a => a.url));
|
|
178
193
|
const newAssets = analysis.assets.filter(a => !existingUrls.has(a.url));
|
|
179
|
-
|
|
194
|
+
|
|
195
|
+
// Merge props — don't overwrite existing defaults
|
|
196
|
+
const mergedProps = { ...props };
|
|
197
|
+
for (const [key, val] of Object.entries(existing.props || {})) {
|
|
198
|
+
mergedProps[key] = val; // existing props take priority
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const merged = {
|
|
202
|
+
...existing,
|
|
203
|
+
assets: [...(existing.assets || []), ...newAssets],
|
|
204
|
+
dependencies: existing.dependencies || [],
|
|
205
|
+
props: mergedProps
|
|
206
|
+
};
|
|
180
207
|
|
|
181
208
|
await fs.writeJson(metaPath, merged, { spaces: 2 });
|
|
182
209
|
|