webhanger 1.0.1 → 1.0.5

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/bin/cli.js CHANGED
@@ -200,6 +200,60 @@ async function init() {
200
200
  console.log(chalk.green("\nāœ… webhanger.config.json created."));
201
201
  console.log(chalk.yellow(`šŸ”‘ Project ID: ${projectId}`));
202
202
  console.log(chalk.gray("Keep your secretKey safe — it signs all your component URLs.\n"));
203
+
204
+ // ── Optional: Edge Worker setup ───────────────────────────────────────────
205
+ const { useEdge } = await inquirer.prompt([{
206
+ type: "confirm",
207
+ name: "useEdge",
208
+ message: "Setup Cloudflare Edge Worker for token validation + geo routing? (optional, recommended for production):",
209
+ default: false
210
+ }]);
211
+
212
+ if (useEdge) {
213
+ const { workerName } = await inquirer.prompt([{
214
+ type: "input",
215
+ name: "workerName",
216
+ message: "Cloudflare Worker name:",
217
+ default: `webhanger-edge-${answers.projectName.toLowerCase().replace(/\s+/g, "-")}`
218
+ }]);
219
+
220
+ // Write edge/worker.js
221
+ await fs.ensureDir("./edge");
222
+ const workerSrc = await fs.readFile(new URL("../edge/worker.js", import.meta.url), "utf-8").catch(() => null);
223
+ if (workerSrc) await fs.writeFile("./edge/worker.js", workerSrc, "utf-8");
224
+
225
+ // Write wrangler.toml
226
+ const wranglerToml = `name = "${workerName}"
227
+ main = "worker.js"
228
+ compatibility_date = "2024-01-01"
229
+
230
+ [[kv_namespaces]]
231
+ binding = "WH_SECRETS"
232
+ id = "REPLACE_WITH_YOUR_KV_ID"
233
+
234
+ [[kv_namespaces]]
235
+ binding = "WH_VERSIONS"
236
+ id = "REPLACE_WITH_YOUR_KV_ID_2"
237
+
238
+ [vars]
239
+ ORIGIN_DEFAULT = "${cdnUrl}"
240
+ ORIGIN_AP = "${cdnUrl}"
241
+ ORIGIN_EU = "${cdnUrl}"
242
+ `;
243
+ await fs.writeFile("./edge/wrangler.toml", wranglerToml, "utf-8");
244
+
245
+ console.log(chalk.green("\nāœ… Edge worker files created in ./edge/"));
246
+ console.log(chalk.white("\nNext steps to activate edge delivery:"));
247
+ console.log(chalk.gray(" 1. Install wrangler: npm install -g wrangler"));
248
+ console.log(chalk.gray(" 2. Login: wrangler login"));
249
+ console.log(chalk.gray(" 3. Create KV stores: wrangler kv:namespace create WH_SECRETS"));
250
+ console.log(chalk.gray(" wrangler kv:namespace create WH_VERSIONS"));
251
+ console.log(chalk.gray(" 4. Update KV IDs in ./edge/wrangler.toml"));
252
+ console.log(chalk.gray(` 5. Push secret: wrangler kv:key put --binding=WH_SECRETS "secret:${projectId}" "${secretKey}"`));
253
+ console.log(chalk.gray(" 6. Deploy worker: cd edge && wrangler deploy"));
254
+ console.log(chalk.gray(" 7. Update cdn.url in webhanger.config.json to your worker URL"));
255
+ console.log(chalk.yellow("\n⚔ Once deployed, all component requests are validated at the edge.\n"));
256
+ }
203
257
  }
204
258
 
205
259
  async function deployCommand() {
@@ -272,6 +326,402 @@ switch (command) {
272
326
  case "deploy":
273
327
  deployCommand();
274
328
  break;
329
+ case "edge-init": {
330
+ // Pushes projectId + secretKey to Cloudflare KV so the worker can validate tokens
331
+ const loadConfigFn = (await import("../helper/loadConfig.js")).default;
332
+ const config = loadConfigFn();
333
+ const { projectId, secretKey } = config;
334
+
335
+ console.log(chalk.cyan("\n⚔ Initializing edge worker...\n"));
336
+ console.log(chalk.white("Run these commands to push your secrets to Cloudflare KV:\n"));
337
+ console.log(chalk.gray(` wrangler kv:key put --binding=WH_SECRETS "secret:${projectId}" "${secretKey}"`));
338
+ console.log(chalk.gray(`\nFor each deployed component, register its version:`));
339
+ console.log(chalk.gray(` wrangler kv:key put --binding=WH_VERSIONS "version:navbar" "1.0.0"`));
340
+ console.log(chalk.yellow(`\nšŸ“ Edge worker source: edge/worker.js`));
341
+ console.log(chalk.yellow(`šŸ“ Wrangler config: edge/wrangler.toml`));
342
+ console.log(chalk.white(`\nDeploy the worker:`));
343
+ console.log(chalk.gray(` cd edge && wrangler deploy`));
344
+ console.log(chalk.white(`\nThen update your CDN URL in webhanger.config.json to point to the worker:`));
345
+ console.log(chalk.gray(` "cdn": { "url": "https://webhanger-edge.your-subdomain.workers.dev" }`));
346
+ break;
347
+ }
348
+ case "breakdown": {
349
+ const bdDir = args[1];
350
+ if (!bdDir) {
351
+ console.log(chalk.red("Usage: wh breakdown <component-dir>"));
352
+ process.exit(1);
353
+ }
354
+ const { breakdown: runBreakdown } = await import("../helper/breakdown.js");
355
+ console.log(chalk.cyan(`\nšŸ”§ Breaking down ${bdDir}...\n`));
356
+ const result = await runBreakdown(bdDir);
357
+ if (!result) {
358
+ console.log(chalk.gray(" Nothing to break down — files already separated or no embedded styles/scripts found."));
359
+ } else {
360
+ console.log(chalk.green(" āœ… Breakdown complete!"));
361
+ if (result.extracted.html) console.log(chalk.gray(" → index.html"));
362
+ if (result.extracted.css) console.log(chalk.gray(` → style.css (${result.cssLines} lines)`));
363
+ if (result.extracted.js) console.log(chalk.gray(` → script.js (${result.jsLines} lines)`));
364
+ }
365
+ break;
366
+ }
367
+ case "access": {
368
+ const subCmd = args[1]; // grant | revoke | list
369
+ const loadConfigFn = (await import("../helper/loadConfig.js")).default;
370
+ const { grantAccess, revokeAccess, listAccess, generateApiKey } = await import("../helper/accessControl.js");
371
+ const config = loadConfigFn();
372
+ const { projectId, db } = config;
373
+
374
+ if (subCmd === "grant") {
375
+ const { default: inquirerLocal } = await import("inquirer");
376
+ const ans = await inquirerLocal.prompt([
377
+ {
378
+ type: "list",
379
+ name: "role",
380
+ message: "Select role:",
381
+ choices: [
382
+ { name: "owner — full access", value: "owner" },
383
+ { name: "admin — deploy + delete + manage", value: "admin" },
384
+ { name: "deployer — deploy + read only", value: "deployer" },
385
+ { name: "viewer — read only", value: "viewer" }
386
+ ]
387
+ },
388
+ { type: "input", name: "label", message: "Label (e.g. CI/CD, teammate name):", default: "" }
389
+ ]);
390
+ const apiKey = generateApiKey();
391
+ const result = await grantAccess(db, projectId, apiKey, ans.role, ans.label);
392
+ console.log(chalk.green(`\nāœ… Access granted`));
393
+ console.log(chalk.white(` API Key : ${result.apiKey}`));
394
+ console.log(chalk.white(` Role : ${result.role}`));
395
+ console.log(chalk.white(` Permissions : ${result.permissions.join(", ")}`));
396
+ console.log(chalk.yellow(`\n Store this key safely — it won't be shown again.\n`));
397
+
398
+ } else if (subCmd === "revoke") {
399
+ const apiKey = args[2];
400
+ if (!apiKey) { console.log(chalk.red("Usage: wh access revoke <api-key>")); process.exit(1); }
401
+ await revokeAccess(db, projectId, apiKey);
402
+ console.log(chalk.green(`āœ… Access revoked for ${apiKey}`));
403
+
404
+ } else if (subCmd === "list") {
405
+ const entries = await listAccess(db, projectId);
406
+ if (!entries.length) { console.log(chalk.gray("No access entries found.")); break; }
407
+ console.log(chalk.cyan(`\nšŸ”‘ Access entries for project ${projectId}:\n`));
408
+ entries.forEach(e => {
409
+ console.log(chalk.white(` ${e.apiKey.slice(0, 20)}...`));
410
+ console.log(chalk.gray(` Role: ${e.role} | Permissions: ${e.permissions.join(", ")} | Label: ${e.label || "-"}`));
411
+ });
412
+ console.log();
413
+
414
+ } else {
415
+ console.log(chalk.white("Usage:"));
416
+ console.log(chalk.gray(" wh access grant — generate a new API key with a role"));
417
+ console.log(chalk.gray(" wh access revoke <key> — revoke an API key"));
418
+ console.log(chalk.gray(" wh access list — list all access entries"));
419
+ }
420
+ break;
421
+ }
422
+ case "ship": {
423
+ const shipCompDir = args[1];
424
+ const shipSiteDir = args[2];
425
+ const shipVersion = args[3] || "1.0.0";
426
+ const shipOut = args[4] || "./dist";
427
+
428
+ if (!shipCompDir || !shipSiteDir) {
429
+ console.log(chalk.red("Usage: wh ship <components-dir> <site-dir> [version] [out-dir]"));
430
+ console.log(chalk.gray("Example: wh ship ./components ./docs 1.0.0 ./dist"));
431
+ process.exit(1);
432
+ }
433
+
434
+ const { default: fsExtra } = await import("fs-extra");
435
+ const { default: pathMod } = await import("path");
436
+ const { deploy: registryDeploy } = await import("../core/registry.js");
437
+ const { resolve: resolveGraph } = await import("../core/resolver.js");
438
+ const { build } = await import("../core/builder.js");
439
+ const loadConfigFn = (await import("../helper/loadConfig.js")).default;
440
+ const { default: archiver } = await import("archiver");
441
+ const { default: fsSync } = await import("fs");
442
+
443
+ const config = loadConfigFn();
444
+ const { projectId, db } = config;
445
+
446
+ // ── Step 1: Deploy all components ─────────────────────────────────────
447
+ console.log(chalk.cyan(`\nšŸš€ [1/4] Deploying components from ${shipCompDir}...\n`));
448
+
449
+ const entries = await fsExtra.readdir(shipCompDir);
450
+ const compDirs = [];
451
+ for (const entry of entries) {
452
+ const full = pathMod.resolve(shipCompDir, entry);
453
+ const stat = await fsExtra.stat(full);
454
+ if (stat.isDirectory()) compDirs.push({ name: entry, dir: full });
455
+ }
456
+
457
+ if (!compDirs.length) {
458
+ console.log(chalk.red(`No component folders found in ${shipCompDir}`));
459
+ process.exit(1);
460
+ }
461
+
462
+ const deployed = {};
463
+ for (const comp of compDirs) {
464
+ process.stdout.write(` ${comp.name}@${shipVersion}... `);
465
+ try {
466
+ deployed[comp.name] = await registryDeploy(config, comp.dir, comp.name, shipVersion);
467
+ console.log(chalk.green("āœ…"));
468
+ } catch (err) {
469
+ console.log(chalk.red(`āŒ ${err.message}`));
470
+ }
471
+ }
472
+
473
+ // ── Step 2: Resolve dependency graph ──────────────────────────────────
474
+ console.log(chalk.cyan(`\nšŸ” [2/4] Resolving dependency graph...\n`));
475
+
476
+ const allDeps = new Set();
477
+ for (const comp of compDirs) {
478
+ const metaPath = pathMod.join(comp.dir, "webhanger.component.json");
479
+ if (await fsExtra.pathExists(metaPath)) {
480
+ const meta = await fsExtra.readJson(metaPath);
481
+ (meta.dependencies || []).forEach(d => allDeps.add(d.split("@")[0]));
482
+ }
483
+ }
484
+ const roots = compDirs.filter(c => !allDeps.has(c.name));
485
+ console.log(chalk.gray(` Root components: ${roots.map(r => r.name).join(", ")}`));
486
+
487
+ // Write wh-manifest.json into site dir
488
+ const manifest = {
489
+ pid: projectId,
490
+ components: Object.fromEntries(
491
+ Object.entries(deployed).map(([name, d]) => [
492
+ name, { url: d.cdnUrl, urls: d.cdnUrls || [d.cdnUrl], token: d.token, expires: d.expires }
493
+ ])
494
+ )
495
+ };
496
+ await fsExtra.ensureDir(shipSiteDir);
497
+ await fsExtra.writeJson(pathMod.join(shipSiteDir, "wh-manifest.json"), manifest, { spaces: 2 });
498
+ console.log(chalk.gray(` wh-manifest.json written to ${shipSiteDir}`));
499
+
500
+ // ── Step 3: Production build ───────────────────────────────────────────
501
+ console.log(chalk.cyan(`\nšŸ—ļø [3/4] Building ${shipSiteDir} → ${shipOut}...\n`));
502
+
503
+ try {
504
+ const buildResult = await build(shipSiteDir, shipOut);
505
+ // Copy manifest to dist too
506
+ await fsExtra.copy(
507
+ pathMod.join(shipSiteDir, "wh-manifest.json"),
508
+ pathMod.join(shipOut, "wh-manifest.json")
509
+ );
510
+ buildResult.pages.forEach(p => {
511
+ const kb = (p.size / 1024).toFixed(1);
512
+ console.log(chalk.gray(` ${p.fileName.padEnd(25)} ${kb} kB`));
513
+ });
514
+ } catch (err) {
515
+ console.log(chalk.yellow(` Build warning: ${err.message}`));
516
+ }
517
+
518
+ // ── Step 4: Zip ───────────────────────────────────────────────────────
519
+ console.log(chalk.cyan(`\nšŸ“¦ [4/4] Zipping ${shipOut}...\n`));
520
+
521
+ const zipPath = pathMod.join(pathMod.dirname(shipOut), "deploy.zip");
522
+ await new Promise((resolve, reject) => {
523
+ const output = fsSync.createWriteStream(zipPath);
524
+ const archive = archiver("zip", { zlib: { level: 9 } });
525
+ output.on("close", resolve);
526
+ archive.on("error", reject);
527
+ archive.pipe(output);
528
+ archive.directory(shipOut, false);
529
+ archive.finalize();
530
+ });
531
+
532
+ const zipSize = ((await fsExtra.stat(zipPath)).size / 1024).toFixed(1);
533
+
534
+ console.log(chalk.green(`\nāœ… Ship complete!\n`));
535
+ console.log(chalk.white(` Components deployed : ${compDirs.length}`));
536
+ console.log(chalk.white(` Output : ${shipOut}`));
537
+ console.log(chalk.white(` Deploy zip : ${zipPath} (${zipSize} kB)`));
538
+ console.log(chalk.gray(`\n Upload deploy.zip to Netlify, S3, or any static host.\n`));
539
+ break;
540
+ }
541
+ case "graph-deploy": {
542
+ const graphDir = args[1];
543
+ const graphVersion = args[2] || "1.0.0";
544
+ const graphOut = args[3] || "./graph-output";
545
+
546
+ if (!graphDir) {
547
+ console.log(chalk.red("Usage: wh graph-deploy <components-dir> [version] [output-dir]"));
548
+ console.log(chalk.gray("Example: wh graph-deploy ./components 1.0.0 ./output"));
549
+ process.exit(1);
550
+ }
551
+
552
+ const { default: fsExtra } = await import("fs-extra");
553
+ const { default: pathMod } = await import("path");
554
+ const { deploy: registryDeploy } = await import("../core/registry.js");
555
+ const { resolve: resolveGraph } = await import("../core/resolver.js");
556
+ const loadConfigFn = (await import("../helper/loadConfig.js")).default;
557
+
558
+ const config = loadConfigFn();
559
+ const { projectId, db } = config;
560
+
561
+ // Scan all component folders
562
+ const entries = await fsExtra.readdir(graphDir);
563
+ const compDirs = [];
564
+ for (const entry of entries) {
565
+ const full = pathMod.join(graphDir, entry);
566
+ const stat = await fsExtra.stat(full);
567
+ if (stat.isDirectory()) compDirs.push({ name: entry, dir: full });
568
+ }
569
+
570
+ if (!compDirs.length) {
571
+ console.log(chalk.red(`No component folders found in ${graphDir}`));
572
+ process.exit(1);
573
+ }
574
+
575
+ console.log(chalk.cyan(`\nšŸš€ Graph deploying ${compDirs.length} components...\n`));
576
+
577
+ // Deploy all components
578
+ const deployed = {};
579
+ for (const comp of compDirs) {
580
+ process.stdout.write(` ${comp.name}@${graphVersion}... `);
581
+ try {
582
+ deployed[comp.name] = await registryDeploy(config, comp.dir, comp.name, graphVersion);
583
+ console.log(chalk.green("āœ…"));
584
+ } catch (err) {
585
+ console.log(chalk.red(`āŒ ${err.message}`));
586
+ }
587
+ }
588
+
589
+ // Find root component (one that is depended on by none, or named "dashboard"/"app"/"root")
590
+ const allDeps = new Set();
591
+ for (const comp of compDirs) {
592
+ const metaPath = pathMod.join(comp.dir, "webhanger.component.json");
593
+ if (await fsExtra.pathExists(metaPath)) {
594
+ const meta = await fsExtra.readJson(metaPath);
595
+ (meta.dependencies || []).forEach(d => allDeps.add(d.split("@")[0]));
596
+ }
597
+ }
598
+ const roots = compDirs.filter(c => !allDeps.has(c.name));
599
+ const rootName = roots[0]?.name || compDirs[compDirs.length - 1].name;
600
+
601
+ console.log(chalk.cyan(`\nšŸ” Resolving graph from root: ${rootName}@${graphVersion}\n`));
602
+
603
+ let graph;
604
+ try {
605
+ graph = await resolveGraph(db, projectId, rootName, graphVersion);
606
+ } catch (err) {
607
+ console.log(chalk.red(`āŒ Graph resolution failed: ${err.message}`));
608
+ process.exit(1);
609
+ }
610
+
611
+ console.log(chalk.white("Load order:"));
612
+ graph.forEach((c, i) => console.log(chalk.gray(` ${i + 1}. ${c.name}@${c.version}`)));
613
+
614
+ // Generate manifest + HTML
615
+ await fsExtra.ensureDir(graphOut);
616
+ const selectors = graph.reduce((acc, c) => {
617
+ acc[c.name] = `[data-wh-${c.name}]`;
618
+ return acc;
619
+ }, {});
620
+
621
+ // Write wh-manifest.json — sensitive data stays out of HTML
622
+ const manifest = {
623
+ pid: projectId,
624
+ components: graph.reduce((acc, c) => {
625
+ acc[c.name] = { url: c.cdnUrl, token: c.token, expires: c.expires };
626
+ return acc;
627
+ }, {})
628
+ };
629
+ await fsExtra.writeJson(pathMod.join(graphOut, "wh-manifest.json"), manifest, { spaces: 2 });
630
+
631
+ const mounts = graph.map(c => ` <div data-wh-${c.name}></div>`).join("\n");
632
+
633
+ const html = `<!DOCTYPE html>
634
+ <html lang="en">
635
+ <head>
636
+ <meta charset="UTF-8">
637
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
638
+ <title>WebHanger — ${rootName}</title>
639
+ <style>* { margin:0; padding:0; box-sizing:border-box; } body { background:#030712; }</style>
640
+ </head>
641
+ <body>
642
+ ${mounts}
643
+
644
+ <script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
645
+ <script>
646
+ const selectors = ${JSON.stringify(selectors, null, 2)};
647
+ const rootName = "${rootName}";
648
+
649
+ async function loadGraph() {
650
+ const res = await fetch("./wh-manifest.json");
651
+ const m = await res.json();
652
+ await WebHangerFront.clearCache();
653
+
654
+ // Inject root first so nested mount points exist in DOM
655
+ const root = m.components[rootName];
656
+ if (root) await WebHangerFront.load(root.url, m.pid, root.token, root.expires, selectors[rootName]);
657
+
658
+ // Then inject deps into their mount points
659
+ for (const [name, comp] of Object.entries(m.components)) {
660
+ if (name === rootName) continue;
661
+ const sel = selectors[name];
662
+ if (sel) await WebHangerFront.load(comp.url, m.pid, comp.token, comp.expires, sel);
663
+ }
664
+ }
665
+
666
+ loadGraph();
667
+ </script>
668
+ </body>
669
+ </html>`;
670
+
671
+ const outFile = pathMod.join(graphOut, "index.html");
672
+ await fsExtra.writeFile(outFile, html, "utf-8");
673
+
674
+ console.log(chalk.green(`\nāœ… Done! Output: ${outFile}`));
675
+ break;
676
+ }
677
+ case "zip": {
678
+ const srcDir = args[1] || "./dist";
679
+ const outFile = args[2] || "./deploy.zip";
680
+ const { default: archiver } = await import("archiver");
681
+ const { default: fsSync } = await import("fs");
682
+
683
+ console.log(chalk.cyan(`\nšŸ“¦ Zipping ${srcDir} → ${outFile}...`));
684
+
685
+ try {
686
+ await new Promise((resolve, reject) => {
687
+ const output = fsSync.createWriteStream(outFile);
688
+ const archive = archiver("zip", { zlib: { level: 9 } });
689
+ output.on("close", resolve);
690
+ archive.on("error", reject);
691
+ archive.pipe(output);
692
+ archive.directory(srcDir, false); // false = no parent folder in zip
693
+ archive.finalize();
694
+ });
695
+
696
+ const { default: fsExtra } = await import("fs-extra");
697
+ const size = ((await fsExtra.stat(outFile)).size / 1024).toFixed(1);
698
+ console.log(chalk.green(`\nāœ… ${outFile} created (${size} kB)`));
699
+ console.log(chalk.gray(" Upload and unzip to any static host to deploy."));
700
+ } catch (err) {
701
+ console.log(chalk.red(`\nāŒ Zip failed: ${err.message}`));
702
+ process.exit(1);
703
+ }
704
+ break;
705
+ }
706
+ case "build": {
707
+ const srcDir = args[1] || ".";
708
+ const outDir = args[2] || "./dist";
709
+ const { build } = await import("../core/builder.js");
710
+ console.log(chalk.cyan(`\nšŸ—ļø Building ${srcDir} → ${outDir}...\n`));
711
+ try {
712
+ const result = await build(srcDir, outDir);
713
+ console.log(chalk.green(`āœ… Build complete!\n`));
714
+ result.pages.forEach(p => {
715
+ const kb = (p.size / 1024).toFixed(1);
716
+ console.log(chalk.white(` ${p.fileName.padEnd(25)} ${kb} kB`));
717
+ });
718
+ console.log(chalk.gray(`\n Output: ${result.outDir}`));
719
+ } catch (err) {
720
+ console.log(chalk.red(`\nāŒ Build failed: ${err.message}`));
721
+ process.exit(1);
722
+ }
723
+ break;
724
+ }
275
725
  case "convert": {
276
726
  const [,,,convertDir, convertName, convertTarget, convertOut] = process.argv;
277
727
  if (!convertDir || !convertName || !convertTarget) {
@@ -318,9 +768,14 @@ switch (command) {
318
768
  default:
319
769
  console.log(chalk.cyan(BANNER));
320
770
  console.log(chalk.white("Commands:"));
321
- console.log(chalk.gray(" wh init — setup your project"));
322
- console.log(chalk.gray(" wh deploy <dir> <name> <version> — bundle & deploy a component"));
323
- console.log(chalk.gray(" wh analyze <dir> — detect framework, styling & CDN deps"));
324
- console.log(chalk.gray(" wh convert <dir> <name> <target> [output-dir] — convert to react/next/vue/svelte/angular/astro"));
771
+ console.log(chalk.gray(" wh init — setup your project"));
772
+ console.log(chalk.gray(" wh ship <comp-dir> <site-dir> [version] [out-dir] — deploy + build + zip in one shot"));
773
+ console.log(chalk.gray(" wh deploy <dir> <name> <version> — deploy a single component"));
774
+ console.log(chalk.gray(" wh graph-deploy <comp-dir> [version] [out-dir] — deploy all + resolve dep graph"));
775
+ console.log(chalk.gray(" wh build <src-dir> [out-dir] — production build"));
776
+ console.log(chalk.gray(" wh zip <src-dir> [out-file] — zip for deployment"));
777
+ console.log(chalk.gray(" wh breakdown <dir> — extract CSS/JS from single HTML file"));
778
+ console.log(chalk.gray(" wh access grant|revoke|list — role-based access control"));
779
+ console.log(chalk.gray(" wh convert <dir> <name> <target> [out-dir] — convert to react/vue/svelte/next/astro"));
325
780
  break;
326
781
  }
@@ -0,0 +1,133 @@
1
+ import fs from "fs-extra";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+
5
+ // ─── Minifiers ────────────────────────────────────────────────────────────────
6
+
7
+ function minifyHtml(html) {
8
+ return html
9
+ .replace(/<!--[\s\S]*?-->/g, "") // remove comments
10
+ .replace(/\s+/g, " ") // collapse whitespace
11
+ .replace(/>\s+</g, "><") // remove whitespace between tags
12
+ .replace(/\s+>/g, ">") // trim before >
13
+ .replace(/<\s+/g, "<") // trim after <
14
+ .trim();
15
+ }
16
+
17
+ function minifyCss(css) {
18
+ return css
19
+ .replace(/\/\*[\s\S]*?\*\//g, "") // remove comments
20
+ .replace(/\s+/g, " ") // collapse whitespace
21
+ .replace(/\s*([{}:;,>~+])\s*/g, "$1") // remove spaces around operators
22
+ .replace(/;}/g, "}") // remove last semicolon
23
+ .trim();
24
+ }
25
+
26
+ function minifyJs(js) {
27
+ return js
28
+ .replace(/\/\/[^\n]*/g, "") // remove single line comments
29
+ .replace(/\/\*[\s\S]*?\*\//g, "") // remove block comments
30
+ .replace(/\s+/g, " ") // collapse whitespace
31
+ .replace(/\s*([=+\-*/{};:,()[\]])\s*/g, "$1") // spaces around operators
32
+ .trim();
33
+ }
34
+
35
+ // ─── Hash helper ──────────────────────────────────────────────────────────────
36
+
37
+ function hashContent(content) {
38
+ return crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
39
+ }
40
+
41
+ // ─── Process a single HTML file ───────────────────────────────────────────────
42
+
43
+ async function processHtml(filePath, outDir, assetMap) {
44
+ let html = await fs.readFile(filePath, "utf-8");
45
+
46
+ // Inline local <link rel="stylesheet"> files
47
+ html = await replaceAsync(html, /<link[^>]+href="([^"]+\.css)"[^>]*>/g, async (match, href) => {
48
+ if (href.startsWith("http")) return match; // skip external
49
+ const cssPath = path.resolve(path.dirname(filePath), href);
50
+ if (!await fs.pathExists(cssPath)) return match;
51
+ const css = minifyCss(await fs.readFile(cssPath, "utf-8"));
52
+ return `<style>${css}</style>`;
53
+ });
54
+
55
+ // Inline local <script src="..."> files (non-module, non-external)
56
+ html = await replaceAsync(html, /<script[^>]+src="([^"]+\.js)"[^>]*><\/script>/g, async (match, src) => {
57
+ if (src.startsWith("http") || src.includes("unpkg") || src.includes("cdn")) return match;
58
+ const jsPath = path.resolve(path.dirname(filePath), src);
59
+ if (!await fs.pathExists(jsPath)) return match;
60
+ const js = minifyJs(await fs.readFile(jsPath, "utf-8"));
61
+ return `<script>${js}</script>`;
62
+ });
63
+
64
+ // Minify the HTML itself
65
+ html = minifyHtml(html);
66
+
67
+ // Write to dist
68
+ const fileName = path.basename(filePath);
69
+ const outPath = path.join(outDir, fileName);
70
+ await fs.writeFile(outPath, html, "utf-8");
71
+
72
+ return { fileName, size: Buffer.byteLength(html, "utf-8") };
73
+ }
74
+
75
+ // ─── Async replace helper ─────────────────────────────────────────────────────
76
+
77
+ async function replaceAsync(str, regex, asyncFn) {
78
+ const promises = [];
79
+ str.replace(regex, (match, ...args) => {
80
+ promises.push(asyncFn(match, ...args));
81
+ return match;
82
+ });
83
+ const results = await Promise.all(promises);
84
+ return str.replace(regex, () => results.shift());
85
+ }
86
+
87
+ // ─── Main build function ──────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Builds a WebHanger site for production.
91
+ * @param {string} srcDir - source directory with HTML files
92
+ * @param {string} outDir - output directory (default: ./dist)
93
+ */
94
+ export async function build(srcDir, outDir = "./dist") {
95
+ const absOut = path.resolve(outDir);
96
+ await fs.ensureDir(absOut);
97
+ await fs.emptyDir(absOut);
98
+
99
+ const files = await fs.readdir(srcDir);
100
+ const htmlFiles = files.filter(f => f.endsWith(".html"));
101
+
102
+ if (!htmlFiles.length) throw new Error(`No HTML files found in ${srcDir}`);
103
+
104
+ const results = [];
105
+ const assetMap = {};
106
+
107
+ for (const file of htmlFiles) {
108
+ const filePath = path.join(srcDir, file);
109
+ const result = await processHtml(filePath, absOut, assetMap);
110
+ results.push(result);
111
+ }
112
+
113
+ // Copy non-HTML assets (images, fonts, etc.) preserving structure
114
+ for (const file of files) {
115
+ if (file.endsWith(".html")) continue;
116
+ const src = path.join(srcDir, file);
117
+ const stat = await fs.stat(src);
118
+ if (stat.isFile()) {
119
+ await fs.copy(src, path.join(absOut, file));
120
+ }
121
+ }
122
+
123
+ // Write build manifest
124
+ const manifest = {
125
+ builtAt: new Date().toISOString(),
126
+ srcDir,
127
+ outDir: absOut,
128
+ pages: results
129
+ };
130
+ await fs.writeJson(path.join(absOut, "build-manifest.json"), manifest, { spaces: 2 });
131
+
132
+ return { outDir: absOut, pages: results };
133
+ }
package/core/registry.js CHANGED
@@ -16,14 +16,23 @@ export async function deploy(config, componentDir, name, version, dependencies =
16
16
  // 1. Bundle + encode
17
17
  const bundledJs = await bundle(componentDir, projectId);
18
18
 
19
- // 2. Upload
19
+ // 2. Extract dependencies declared in webhanger.component.json
20
+ let parsedPayload = {};
21
+ try { parsedPayload = JSON.parse(bundledJs); } catch (_) {}
22
+ const resolvedDeps = dependencies.length ? dependencies : (parsedPayload.dependencies || []);
23
+
24
+ // 3. Upload
20
25
  const storageKey = `components/${name}@${version}.js`;
21
26
  await upload(storage, storageKey, bundledJs);
22
27
 
23
- // 3. Sign — use custom token if provided, otherwise HMAC-generate
28
+ // 4. Generate CDN URLs — primary + fallbacks for multi-CDN failover
24
29
  const cdnUrl = `${cdn.url}/${storageKey}`;
25
- let token, expires;
30
+ const cdnUrls = [cdnUrl];
31
+ if (cdn.fallbacks && cdn.fallbacks.length) {
32
+ cdn.fallbacks.forEach(fb => cdnUrls.push(`${fb}/${storageKey}`));
33
+ }
26
34
 
35
+ let token, expires;
27
36
  if (customToken) {
28
37
  expires = expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0;
29
38
  token = customToken;
@@ -31,8 +40,8 @@ export async function deploy(config, componentDir, name, version, dependencies =
31
40
  ({ token, expires } = signUrl(storageKey, projectId, secretKey, expiresInSeconds));
32
41
  }
33
42
 
34
- // 4. Register in DB
35
- await registerComponent(db, projectId, { name, version, cdnUrl, token, expires, dependencies });
43
+ // 5. Register with all CDN URLs
44
+ await registerComponent(db, projectId, { name, version, cdnUrl, cdnUrls, token, expires, dependencies: resolvedDeps });
36
45
 
37
- return { cdnUrl, token, expires };
46
+ return { cdnUrl, cdnUrls, token, expires, dependencies: resolvedDeps };
38
47
  }