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 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
- ## OAuth Authentication (webhanger-auth)
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
+ }
@@ -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
- let existing = { assets: [] };
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
- const merged = { ...existing, assets: [...existing.assets, ...newAssets] };
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhanger",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
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",